PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
2.13.0 2.12.0 2.11.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 All 54 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.13.0, at includes/abilities/import/class-run-seo-import.php

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