PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.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 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / abilities / import / class-preview-seo-import.php

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

288 lines 7.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Preview SEO import (dry-run) 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\Yoast_Exporter;
18
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit; // Exit if accessed directly.
21 }
22
23 /**
24 * Dry-run an SEO import and report what it WOULD do, writing nothing to
25 * ThinkRank (see #190).
26 *
27 * It exports the chosen source (Yoast/RankMath/SEOPress/AIOSEO) into a snapshot
28 * — the same non-destructive read phase the real import uses — then classifies
29 * every object-meta record without migrating: how many would be written, how
30 * many conflict with an existing ThinkRank value that a migrate would not
31 * overwrite, and how many reference posts/terms/users that no longer exist.
32 * Run this before `run-seo-import` to see the impact up front.
33 */
34 class Preview_Seo_Import extends Ability_Base {
35 /**
36 * Source plugins that can be previewed.
37 *
38 * @var string[]
39 */
40 private const ALLOWED_PLUGINS = [ 'yoast', 'rankmath', 'seopress', 'aioseo' ];
41
42 /**
43 * Data types exported before previewing, in pipeline order.
44 *
45 * @var string[]
46 */
47 private const EXPORT_TYPES = [ 'postmeta', 'termmeta', 'usermeta', 'redirections', 'settings' ];
48
49 /**
50 * Object-meta types the dry-run classifies (settings/redirections/404 logs
51 * are migrated wholesale and aren't previewed per-record).
52 *
53 * @var string[]
54 */
55 private const PREVIEW_TYPES = [ 'postmeta', 'termmeta', 'usermeta' ];
56
57 /**
58 * Safety cap on chunk iterations per type.
59 */
60 private const MAX_PAGES = 1000;
61
62 /**
63 * Constructor.
64 */
65 public function __construct() {
66 $this->id = 'thinkrank/preview-seo-import';
67 $this->label = __( 'Preview SEO Import (Dry Run)', 'thinkrank' );
68 $this->description = __( 'Dry-run an SEO import from another plugin (Yoast, RankMath, SEOPress, or AIOSEO) and report what it would do without writing anything to ThinkRank: counts of records that would be written, records that conflict with existing ThinkRank values (which are never overwritten), and records whose post/term/user no longer exists. Returns totals plus small samples. Run before run-seo-import.', 'thinkrank' );
69 }
70
71 /**
72 * {@inheritDoc}
73 *
74 * @return array<string, bool|float|string>
75 */
76 public function get_annotations() {
77 return [
78 'readonly' => false,
79 'destructive' => false,
80 'idempotent' => true,
81 'priority' => 1.5,
82 'openWorldHint' => false,
83 ];
84 }
85
86 /**
87 * {@inheritDoc}
88 *
89 * @return array<string, mixed>
90 */
91 public function get_input_schema() {
92 return [
93 'type' => 'object',
94 'additionalProperties' => false,
95 'properties' => [
96 'plugin' => [
97 'type' => 'string',
98 'description' => __( 'The source SEO plugin to preview an import from.', 'thinkrank' ),
99 'enum' => self::ALLOWED_PLUGINS,
100 ],
101 ],
102 'required' => [ 'plugin' ],
103 ];
104 }
105
106 /**
107 * {@inheritDoc}
108 *
109 * @return array<string, mixed>
110 */
111 public function get_output_schema() {
112 return [
113 'type' => 'object',
114 'properties' => [
115 'plugin' => [ 'type' => 'string' ],
116 'records' => [ 'type' => 'integer' ],
117 'would_write' => [ 'type' => 'integer' ],
118 'conflicts' => [ 'type' => 'integer' ],
119 'unmatched' => [ 'type' => 'integer' ],
120 'skipped' => [ 'type' => 'integer' ],
121 'by_type' => [
122 'type' => 'object',
123 'additionalProperties' => true,
124 ],
125 'samples' => [
126 'type' => 'object',
127 'additionalProperties' => true,
128 ],
129 'errors' => [
130 'type' => 'array',
131 'items' => [ 'type' => 'string' ],
132 ],
133 ],
134 ];
135 }
136
137 /**
138 * Execute ability.
139 *
140 * @param array<string, mixed> $input Ability input payload.
141 * @return array<string, mixed>|\WP_Error
142 */
143 public function execute( $input ) {
144 $plugin = isset( $input['plugin'] ) ? sanitize_key( (string) $input['plugin'] ) : '';
145
146 if ( ! in_array( $plugin, self::ALLOWED_PLUGINS, true ) ) {
147 return new \WP_Error(
148 'thinkrank_invalid_import_plugin',
149 __( 'A supported source plugin is required (yoast, rankmath, seopress, aioseo).', 'thinkrank' ),
150 [ 'status' => 400 ]
151 );
152 }
153
154 $exporter = $this->get_exporter( $plugin );
155 if ( null === $exporter ) {
156 return new \WP_Error(
157 'thinkrank_invalid_import_plugin',
158 __( 'Could not resolve an exporter for the selected plugin.', 'thinkrank' ),
159 [ 'status' => 400 ]
160 );
161 }
162
163 try {
164 $this->run_export( $exporter );
165 } catch ( \Throwable $e ) {
166 return new \WP_Error(
167 'thinkrank_import_preview_failed',
168 $e->getMessage(),
169 [ 'status' => 500 ]
170 );
171 }
172
173 return $this->run_preview( $plugin );
174 }
175
176 /**
177 * Export every data type into a fresh snapshot and finalize it (the
178 * non-destructive read phase). Writes nothing to ThinkRank.
179 *
180 * @param object $exporter Source-plugin exporter.
181 * @return void
182 */
183 private function run_export( $exporter ) {
184 foreach ( self::EXPORT_TYPES as $type ) {
185 $page = 1;
186 do {
187 $result = $exporter->export_chunk( $type, $page );
188 $has_more = is_array( $result ) && ! empty( $result['has_more'] );
189 ++$page;
190 } while ( $has_more && $page <= self::MAX_PAGES );
191 }
192
193 $exporter->finalize_export();
194 }
195
196 /**
197 * Dry-run the snapshot and aggregate the per-type preview counts.
198 *
199 * @param string $plugin Source plugin slug.
200 * @return array<string, mixed>
201 */
202 private function run_preview( $plugin ) {
203 $migrator = new Snapshot_Migrator();
204
205 $totals = [
206 'records' => 0,
207 'would_write' => 0,
208 'conflicts' => 0,
209 'unmatched' => 0,
210 'skipped' => 0,
211 ];
212 $by_type = [];
213 $samples = [ 'would_write' => [], 'conflicts' => [], 'unmatched' => [] ];
214 $errors = [];
215
216 foreach ( self::PREVIEW_TYPES as $type ) {
217 $type_totals = [
218 'records' => 0,
219 'would_write' => 0,
220 'conflicts' => 0,
221 'unmatched' => 0,
222 'skipped' => 0,
223 ];
224
225 $page = 1;
226 do {
227 $preview = $migrator->preview_chunk( $plugin, $type, $page );
228
229 if ( ! empty( $preview['error'] ) ) {
230 $errors[] = (string) $preview['error'];
231 break;
232 }
233
234 foreach ( array_keys( $type_totals ) as $key ) {
235 $type_totals[ $key ] += (int) ( $preview[ $key ] ?? 0 );
236 }
237
238 foreach ( [ 'would_write', 'conflicts', 'unmatched' ] as $bucket ) {
239 foreach ( (array) ( $preview['samples'][ $bucket ] ?? [] ) as $sample ) {
240 if ( count( $samples[ $bucket ] ) < 10 ) {
241 $samples[ $bucket ][] = array_merge( [ 'type' => $type ], $sample );
242 }
243 }
244 }
245
246 $has_more = ! empty( $preview['has_more'] );
247 ++$page;
248 } while ( $has_more && $page <= self::MAX_PAGES );
249
250 $by_type[ $type ] = $type_totals;
251 foreach ( array_keys( $totals ) as $key ) {
252 $totals[ $key ] += $type_totals[ $key ];
253 }
254 }
255
256 return array_merge(
257 [ 'plugin' => $plugin ],
258 $totals,
259 [
260 'by_type' => $by_type,
261 'samples' => $samples,
262 'errors' => $errors,
263 ]
264 );
265 }
266
267 /**
268 * Resolve the exporter for a source plugin.
269 *
270 * @param string $plugin Source plugin slug.
271 * @return object|null Exporter instance or null when unknown.
272 */
273 private function get_exporter( $plugin ) {
274 switch ( $plugin ) {
275 case 'yoast':
276 return new Yoast_Exporter();
277 case 'rankmath':
278 return new Rankmath_Exporter();
279 case 'seopress':
280 return new SEOPress_Exporter();
281 case 'aioseo':
282 return new AIOSEO_Exporter();
283 default:
284 return null;
285 }
286 }
287 }
288