PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / abilities / import / class-run-seo-import.php

class-run-seo-import.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/abilities/import/class-run-seo-import.php

362 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Run SEO import ability.
4 *
5 * @package ThinkRank\Abilities\Import
6 */
7
8 declare(strict_types=1);
9
10 namespace ThinkRank\Abilities\Import;
11
12 use ThinkRank\Abilities\Ability_Base;
13 use ThinkRank\Admin\Importers\AIOSEO_Exporter;
14 use ThinkRank\Admin\Importers\Rankmath_Exporter;
15 use ThinkRank\Admin\Importers\SEOPress_Exporter;
16 use ThinkRank\Admin\Importers\Snapshot_Migrator;
17 use ThinkRank\Admin\Importers\Squirrly_Exporter;
18 use ThinkRank\Admin\Importers\Yoast_Exporter;
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit; // Exit if accessed directly.
22 }
23
24 /**
25 * Runs a full SEO data import from another plugin into ThinkRank.
26 *
27 * Composes the two-phase pipeline the REST controller exposes as separate
28 * endpoints: it exports the chosen source (Yoast/RankMath/SEOPress/AIOSEO) into
29 * a snapshot, then migrates that snapshot into ThinkRank's `_thinkrank_*`
30 * metadata. Existing ThinkRank values are never overwritten. Source-plugin data
31 * is left intact (no cleanup); reports aggregate counters, not per-item rows.
32 *
33 * One step is not metadata: `content_blocks` rewrites Rank Math FAQ / HowTo
34 * blocks inside `post_content` into ThinkRank blocks (#777). The description
35 * says so, and `types` lets a caller leave it out.
36 */
37 class Run_Seo_Import extends Ability_Base {
38 /**
39 * Source plugins that can be imported.
40 *
41 * @var string[]
42 */
43 private const ALLOWED_PLUGINS = [ 'yoast', 'rankmath', 'seopress', 'aioseo', 'squirrly' ];
44
45 /**
46 * Data types processed, in pipeline order.
47 *
48 * @var string[]
49 */
50 private const TYPES = [ 'postmeta', 'termmeta', 'usermeta', 'redirections', 'content_blocks', 'settings' ];
51
52 /**
53 * Safety cap on chunk iterations per type (avoids runaway loops).
54 */
55 private const MAX_PAGES = 1000;
56
57 /**
58 * Constructor.
59 */
60 public function __construct() {
61 $this->id = 'thinkrank/run-seo-import';
62 $this->label = __( 'Run SEO Data Import', 'thinkrank' );
63 $this->description = __( 'Import SEO data from another plugin (Yoast, RankMath, SEOPress, AIOSEO, or Squirrly) into ThinkRank. Exports the source to a snapshot then migrates it; existing ThinkRank values are never overwritten and the source plugin\'s own meta and settings are left intact. The content_blocks type (RankMath only) edits post content: it rewrites RankMath FAQ and HowTo blocks into ThinkRank blocks. Each converted post gets a revision, or a restorable backup where revisions are disabled. Pass types to limit the run, for example to leave content_blocks out. Posts with malformed block markup are left unchanged and listed in errors. Returns aggregate export/migration counters. Run preview-seo-import first to see what would change; get-import-status reports on the snapshot afterwards.', 'thinkrank' );
64 }
65
66 /**
67 * {@inheritDoc}
68 *
69 * @return array<string, bool|float|string>
70 */
71 public function get_annotations() {
72 return [
73 'readonly' => false,
74 // Still false, deliberately, although content_blocks edits
75 // post_content. `destructive` is for losses that cannot be undone
76 // (#675), and this one can: every converted post gets a revision,
77 // and where revisions are off Block_Converter keeps the original
78 // in post meta for POST /import/content-blocks/restore. A post
79 // whose markup cannot be converted safely is not written at all.
80 // The description spells the content edit out instead, so the
81 // caller can decide, and `types` can leave the step out.
82 'destructive' => false,
83 'idempotent' => false,
84 'priority' => 2.0,
85 'openWorldHint' => false,
86 ];
87 }
88
89 /**
90 * {@inheritDoc}
91 *
92 * @return array<string, mixed>
93 */
94 public function get_input_schema() {
95 return [
96 'type' => 'object',
97 'additionalProperties' => false,
98 'properties' => [
99 'plugin' => [
100 'type' => 'string',
101 'description' => __( 'The source SEO plugin to import from.', 'thinkrank' ),
102 'enum' => self::ALLOWED_PLUGINS,
103 ],
104 'types' => [
105 'type' => 'array',
106 'description' => __( 'Data types to import. Defaults to all of them. Omit content_blocks to leave post content untouched.', 'thinkrank' ),
107 'items' => [
108 'type' => 'string',
109 'enum' => self::TYPES,
110 ],
111 'minItems' => 1,
112 'uniqueItems' => true,
113 ],
114 ],
115 'required' => [ 'plugin' ],
116 ];
117 }
118
119 /**
120 * {@inheritDoc}
121 *
122 * @return array<string, mixed>
123 */
124 public function get_output_schema() {
125 return [
126 'type' => 'object',
127 'properties' => [
128 'success' => [ 'type' => 'boolean' ],
129 'plugin' => [ 'type' => 'string' ],
130 'exported' => [
131 'type' => 'object',
132 'additionalProperties' => true,
133 ],
134 'types' => [
135 'type' => 'array',
136 'items' => [ 'type' => 'string' ],
137 ],
138 'migrated' => [
139 'type' => 'object',
140 'additionalProperties' => true,
141 ],
142 'errors' => [
143 'type' => 'array',
144 'items' => [ 'type' => 'string' ],
145 ],
146 ],
147 ];
148 }
149
150 /**
151 * Execute ability.
152 *
153 * @param array<string, mixed> $input Ability input payload.
154 * @return array<string, mixed>|\WP_Error
155 */
156 public function execute( $input ) {
157 $plugin = isset( $input['plugin'] ) ? sanitize_key( (string) $input['plugin'] ) : '';
158
159 if ( ! in_array( $plugin, self::ALLOWED_PLUGINS, true ) ) {
160 return new \WP_Error(
161 'thinkrank_invalid_import_plugin',
162 __( 'A supported source plugin is required (yoast, rankmath, seopress, aioseo, squirrly).', 'thinkrank' ),
163 [ 'status' => 400 ]
164 );
165 }
166
167 $types = $this->resolve_types( $input['types'] ?? null );
168
169 if ( is_wp_error( $types ) ) {
170 return $types;
171 }
172
173 $exporter = $this->get_exporter( $plugin );
174
175 if ( null === $exporter ) {
176 return new \WP_Error(
177 'thinkrank_invalid_import_plugin',
178 __( 'Could not resolve an exporter for the selected plugin.', 'thinkrank' ),
179 [ 'status' => 400 ]
180 );
181 }
182
183 try {
184 $exported = $this->run_export( $exporter, $types );
185 $migrated = $this->run_migration( $plugin, $types );
186 } catch ( \Throwable $e ) {
187 return new \WP_Error(
188 'thinkrank_import_failed',
189 $e->getMessage(),
190 [ 'status' => 500 ]
191 );
192 }
193
194 return [
195 'success' => empty( $migrated['errors'] ),
196 'plugin' => $plugin,
197 'types' => $types,
198 'exported' => $exported,
199 'migrated' => [
200 'processed' => $migrated['processed'],
201 'skipped' => $migrated['skipped'],
202 'failed' => $migrated['failed'],
203 'analyzed' => $migrated['analyzed'],
204 'keywords_seeded' => $migrated['keywords_seeded'],
205 ],
206 'errors' => $migrated['errors'],
207 ];
208 }
209
210 /**
211 * The types to run, in pipeline order.
212 *
213 * Absent means every type, which is what the ability did before `types`
214 * existed. Order always follows TYPES rather than the caller's list, since
215 * the migrator relies on postmeta running before settings.
216 *
217 * @param mixed $requested The `types` input, if any.
218 * @return string[]|\WP_Error
219 */
220 private function resolve_types( $requested ) {
221 if ( null === $requested ) {
222 return self::TYPES;
223 }
224
225 if ( ! is_array( $requested ) || empty( $requested ) ) {
226 return new \WP_Error(
227 'thinkrank_invalid_import_types',
228 __( 'types must be a non-empty list of data types.', 'thinkrank' ),
229 [ 'status' => 400 ]
230 );
231 }
232
233 $requested = array_map( 'sanitize_key', array_map( 'strval', $requested ) );
234 $unknown = array_diff( $requested, self::TYPES );
235
236 if ( ! empty( $unknown ) ) {
237 return new \WP_Error(
238 'thinkrank_invalid_import_types',
239 sprintf(
240 /* translators: 1: unknown type slugs, 2: allowed type slugs. */
241 __( 'Unknown import types: %1$s. Allowed: %2$s.', 'thinkrank' ),
242 implode( ', ', $unknown ),
243 implode( ', ', self::TYPES )
244 ),
245 [ 'status' => 400 ]
246 );
247 }
248
249 return array_values( array_intersect( self::TYPES, $requested ) );
250 }
251
252 /**
253 * Resolve the exporter for a source plugin.
254 *
255 * @param string $plugin Source plugin slug.
256 * @return object|null Exporter instance or null when unknown.
257 */
258 private function get_exporter( $plugin ) {
259 switch ( $plugin ) {
260 case 'yoast':
261 return new Yoast_Exporter();
262 case 'rankmath':
263 return new Rankmath_Exporter();
264 case 'seopress':
265 return new SEOPress_Exporter();
266 case 'aioseo':
267 return new AIOSEO_Exporter();
268 case 'squirrly':
269 return new Squirrly_Exporter();
270 default:
271 return null;
272 }
273 }
274
275 /**
276 * Export every data type into a snapshot and finalize it.
277 *
278 * @param object $exporter Source-plugin exporter.
279 * @param string[] $types Types to export, in pipeline order.
280 * @return array<string, int> Per-type exported counts.
281 */
282 private function run_export( $exporter, array $types ) {
283 $exported = [];
284
285 foreach ( $types as $type ) {
286 $count = 0;
287 $page = 1;
288
289 do {
290 $result = $exporter->export_chunk( $type, $page );
291 $count += is_array( $result ) ? (int) ( $result['exported'] ?? 0 ) : 0;
292 $has_more = is_array( $result ) && ! empty( $result['has_more'] );
293 ++$page;
294 } while ( $has_more && $page <= self::MAX_PAGES );
295
296 $exported[ $type ] = $count;
297 }
298
299 // Flip the manifest status to "complete" so migration is allowed to run.
300 $exporter->finalize_export();
301
302 return $exported;
303 }
304
305 /**
306 * Migrate the snapshot into ThinkRank metadata.
307 *
308 * @param string $plugin Source plugin slug.
309 * @param string[] $types Types to migrate, in pipeline order.
310 * @return array<string, mixed> Aggregate counters and any errors.
311 */
312 private function run_migration( $plugin, array $types ) {
313 $migrator = new Snapshot_Migrator();
314 $totals = [
315 'processed' => 0,
316 'skipped' => 0,
317 'failed' => 0,
318 'analyzed' => 0,
319 'keywords_seeded' => 0,
320 'errors' => [],
321 ];
322
323 foreach ( $types as $type ) {
324 $page = 1;
325
326 do {
327 $result = $migrator->migrate_chunk( $plugin, $type, $page );
328
329 if ( ! is_array( $result ) ) {
330 break;
331 }
332
333 if ( 'error' === ( $result['status'] ?? '' ) ) {
334 $totals['errors'][] = (string) ( $result['message'] ?? 'Unknown migration error.' );
335 break;
336 }
337
338 $totals['processed'] += (int) ( $result['processed'] ?? 0 );
339 $totals['skipped'] += (int) ( $result['skipped'] ?? 0 );
340 $totals['analyzed'] += (int) ( $result['analyzed'] ?? 0 );
341 $totals['keywords_seeded'] += (int) ( $result['keywords_seeded'] ?? 0 );
342 $totals['failed'] += (int) ( $result['failed'] ?? 0 );
343
344 // Per-post failures (content_blocks: a post whose block markup
345 // could not be converted safely and was left as it was). The
346 // chunk itself succeeded, so they are not a chunk error, but
347 // the caller needs the post ids to go and fix them.
348 foreach ( (array) ( $result['failures'] ?? [] ) as $failure ) {
349 $totals['errors'][] = (string) ( $failure['message'] ?? '' );
350 }
351
352 $has_more = ! empty( $result['has_more'] );
353 ++$page;
354 } while ( $has_more && $page <= self::MAX_PAGES );
355 }
356
357 $migrator->update_manifest_migration_info( $plugin );
358
359 return $totals;
360 }
361 }
362