PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 / admin / importers / class-import-controller.php

class-import-controller.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/admin/importers/class-import-controller.php

554 lines 21.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Import Controller
5 *
6 * REST API endpoints for the two-phase SEO data import system.
7 * 6 endpoints under thinkrank/v1/import.
8 *
9 * @package ThinkRank\Admin\Importers
10 * @since 2.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\Admin\Importers;
16
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * Import Controller Class
23 *
24 * @since 2.0.0
25 */
26 class Import_Controller extends \WP_REST_Controller {
27
28 /**
29 * @var string
30 */
31 protected $namespace = 'thinkrank/v1';
32
33 /**
34 * @var string
35 */
36 protected $rest_base = 'import';
37
38 /**
39 * Source plugins ThinkRank can migrate FROM.
40 *
41 * Kept separate from ALLOWED_PLUGINS because cleanup() deletes the source
42 * plugin's live data: the native ThinkRank slug must never reach it, or the
43 * endpoint gains a path that wipes our own meta and options.
44 */
45 private const SOURCE_PLUGINS = ['yoast', 'rankmath', 'seopress', 'aioseo'];
46
47 /**
48 * Snapshot slug for ThinkRank's own data (export / backup / restore).
49 */
50 private const NATIVE_PLUGIN = Thinkrank_Exporter::SLUG;
51
52 /**
53 * Allowed plugin slugs for the snapshot endpoints (export, migrate,
54 * snapshot delete). Includes the native slug; cleanup uses SOURCE_PLUGINS.
55 */
56 private const ALLOWED_PLUGINS = ['yoast', 'rankmath', 'seopress', 'aioseo', 'thinkrank'];
57
58 /**
59 * Allowed export/migrate types. Must cover every type the exporters and
60 * the frontend workflow (useImportWorkflow.js EXPORT_TYPES) can send —
61 * 404_logs was missing, so Rank Math's 404 Monitor could never be
62 * exported or migrated through REST.
63 */
64 private const ALLOWED_TYPES = ['postmeta', 'termmeta', 'usermeta', 'redirections', '404_logs', 'settings'];
65
66 /**
67 * Types the export and migrate endpoints accept.
68 *
69 * The fixed list above plus anything Pro registered through
70 * `thinkrank_export_types` — without this a Pro type would be exportable in
71 * principle and rejected at the route.
72 *
73 * @return string[]
74 */
75 private function get_allowed_types(): array {
76 return array_values(array_unique(array_merge(
77 self::ALLOWED_TYPES,
78 Thinkrank_Exporter::get_exportable_types()
79 )));
80 }
81
82 /**
83 * Register REST routes
84 *
85 * @return void
86 */
87 public function register_routes(): void {
88 register_rest_route($this->namespace, '/' . $this->rest_base . '/detect', [
89 [
90 'methods' => \WP_REST_Server::READABLE,
91 'callback' => [$this, 'detect'],
92 'permission_callback' => [$this, 'check_permissions'],
93 ],
94 ]);
95
96 register_rest_route($this->namespace, '/' . $this->rest_base . '/export', [
97 [
98 'methods' => \WP_REST_Server::CREATABLE,
99 'callback' => [$this, 'export'],
100 'permission_callback' => [$this, 'check_permissions'],
101 'args' => $this->get_export_args(),
102 ],
103 ]);
104
105 register_rest_route($this->namespace, '/' . $this->rest_base . '/snapshots', [
106 [
107 'methods' => \WP_REST_Server::READABLE,
108 'callback' => [$this, 'get_snapshots'],
109 'permission_callback' => [$this, 'check_permissions'],
110 ],
111 ]);
112
113 register_rest_route($this->namespace, '/' . $this->rest_base . '/migrate', [
114 [
115 'methods' => \WP_REST_Server::CREATABLE,
116 'callback' => [$this, 'migrate'],
117 'permission_callback' => [$this, 'check_permissions'],
118 'args' => $this->get_migrate_args(),
119 ],
120 ]);
121
122 register_rest_route($this->namespace, '/' . $this->rest_base . '/cleanup', [
123 [
124 'methods' => \WP_REST_Server::CREATABLE,
125 'callback' => [$this, 'cleanup'],
126 'permission_callback' => [$this, 'check_permissions'],
127 'args' => [
128 'plugin' => [
129 'required' => true,
130 'type' => 'string',
131 // Source plugins only — see SOURCE_PLUGINS.
132 'enum' => self::SOURCE_PLUGINS,
133 'sanitize_callback' => 'sanitize_text_field',
134 ],
135 // Required to proceed while the snapshot still holds
136 // extended data with no migration path (see cleanup()).
137 'force' => [
138 'required' => false,
139 'type' => 'boolean',
140 'default' => false,
141 ],
142 ],
143 ],
144 ]);
145
146 register_rest_route($this->namespace, '/' . $this->rest_base . '/snapshot', [
147 [
148 'methods' => \WP_REST_Server::DELETABLE,
149 'callback' => [$this, 'delete_snapshot'],
150 'permission_callback' => [$this, 'check_permissions'],
151 'args' => [
152 'plugin' => [
153 'required' => true,
154 'type' => 'string',
155 'enum' => self::ALLOWED_PLUGINS,
156 'sanitize_callback' => 'sanitize_text_field',
157 ],
158 ],
159 ],
160 ]);
161 }
162
163 /**
164 * Permission check — manage_options required
165 *
166 * @return bool
167 */
168 public function check_permissions(): bool {
169 return current_user_can('manage_options');
170 }
171
172 /**
173 * GET /import/detect — Detect source plugins, ThinkRank's own exportable
174 * data, and existing snapshots
175 *
176 * @param \WP_REST_Request $request Request object
177 * @return \WP_REST_Response
178 */
179 public function detect(\WP_REST_Request $request): \WP_REST_Response {
180 $detector = new Import_Detector();
181 $detected = $detector->detect();
182 $snapshots = Snapshot_Store::get_available_snapshots();
183
184 return new \WP_REST_Response([
185 'detected' => $detected,
186 // ThinkRank's own exportable data, reported separately from the
187 // source plugins the user can migrate FROM.
188 'native' => $detector->detect_native(),
189 'snapshots' => $snapshots,
190 ], 200);
191 }
192
193 /**
194 * POST /import/export — Export a batch of source data to snapshot
195 *
196 * @param \WP_REST_Request $request Request object
197 * @return \WP_REST_Response|\WP_Error
198 */
199 public function export(\WP_REST_Request $request) {
200 $plugin = $request->get_param('plugin');
201 $type = $request->get_param('type');
202 $page = (int) $request->get_param('page');
203
204 $exporter = $this->get_exporter($plugin);
205 if (is_wp_error($exporter)) {
206 return $exporter;
207 }
208
209 // Start a run from an empty slot. update_manifest() merges into whatever
210 // manifest is already there, so without this the file the user ends up
211 // downloading carries the union of this run and every run before it.
212 if ((bool) $request->get_param('reset')) {
213 Snapshot_Store::delete_snapshot($plugin);
214 }
215
216 $result = $exporter->export_chunk($type, $page);
217
218 // If this type is complete and it's the last type, finalize
219 if (!$result['has_more'] && $request->get_param('is_last_type')) {
220 $exporter->finalize_export();
221 }
222
223 return new \WP_REST_Response($result, 200);
224 }
225
226 /**
227 * GET /import/snapshots — List existing snapshots
228 *
229 * @param \WP_REST_Request $request Request object
230 * @return \WP_REST_Response
231 */
232 public function get_snapshots(\WP_REST_Request $request): \WP_REST_Response {
233 $snapshots = Snapshot_Store::get_available_snapshots();
234 return new \WP_REST_Response(['snapshots' => $snapshots], 200);
235 }
236
237 /**
238 * POST /import/migrate — Migrate a batch from snapshot to ThinkRank meta,
239 * or restore one from ThinkRank's own export
240 *
241 * @param \WP_REST_Request $request Request object
242 * @return \WP_REST_Response
243 */
244 public function migrate(\WP_REST_Request $request): \WP_REST_Response {
245 $plugin = $request->get_param('plugin');
246 $type = $request->get_param('type');
247 $page = (int) $request->get_param('page');
248 $conflict = (string) $request->get_param('conflict');
249
250 $migrator = new Snapshot_Migrator();
251 $result = $migrator->migrate_chunk($plugin, $type, $page, $conflict);
252
253 // If migration is complete for all types, update manifest
254 if (!$result['has_more'] && $request->get_param('is_last_type')) {
255 $migrator->update_manifest_migration_info($plugin);
256 }
257
258 return new \WP_REST_Response($result, 200);
259 }
260
261 /**
262 * POST /import/cleanup — Remove source plugin meta from database
263 *
264 * Deletes SOURCE plugin data only — the ThinkRank snapshot is never touched
265 * by this endpoint (use DELETE /import/snapshot for that). Because the
266 * snapshot may still hold extended data ThinkRank cannot apply yet (e.g.
267 * redirections, owed to the Pro Redirections feature), cleanup is gated:
268 * while such buckets exist the request is rejected with HTTP 409 unless
269 * force=true is passed, so the user explicitly acknowledges that the
270 * snapshot becomes the only copy of that data.
271 *
272 * @param \WP_REST_Request $request Request object
273 * @return \WP_REST_Response|\WP_Error
274 */
275 public function cleanup(\WP_REST_Request $request) {
276 global $wpdb;
277
278 $plugin = $request->get_param('plugin');
279 $force = (bool) $request->get_param('force');
280 $deleted = 0;
281
282 // Belt and braces on top of the route's SOURCE_PLUGINS enum: this
283 // endpoint deletes the SOURCE plugin's live meta and options, so
284 // pointing it at ThinkRank would delete the user's own SEO data.
285 if ($plugin === self::NATIVE_PLUGIN) {
286 return new \WP_Error(
287 'thinkrank_cleanup_not_applicable',
288 __('Cleanup removes a source plugin\'s data and does not apply to ThinkRank\'s own export. Use DELETE /import/snapshot to discard the snapshot.', 'thinkrank'),
289 ['status' => 400]
290 );
291 }
292
293 // Gate: block while the snapshot holds preserved-but-unapplied extended
294 // data, unless the caller explicitly forces the cleanup.
295 if (!$force) {
296 $migrator = new Snapshot_Migrator();
297 $unmigrated = $migrator->get_unmigrated_extended_buckets($plugin);
298
299 if (!empty($unmigrated)) {
300 $labels = array_map(
301 static function (array $bucket): string {
302 return $bucket['count'] > 1
303 ? sprintf('%s (%d)', $bucket['label'], $bucket['count'])
304 : (string) $bucket['label'];
305 },
306 $unmigrated
307 );
308
309 return new \WP_Error(
310 'thinkrank_cleanup_blocked',
311 sprintf(
312 /* translators: %s: comma-separated list of unapplied data buckets. */
313 __('The snapshot still holds data ThinkRank has not applied yet: %s. It stays preserved in the snapshot (cleanup never deletes the snapshot), but the source plugin\'s copy will be removed. Pass force=true to proceed.', 'thinkrank'),
314 implode(', ', $labels)
315 ),
316 [
317 'status' => 409,
318 'preserved' => $unmigrated,
319 'requires_force' => true,
320 ]
321 );
322 }
323 }
324
325 $prefix_map = [
326 'yoast' => '_yoast_wpseo_',
327 'rankmath' => 'rank_math_',
328 'seopress' => '_seopress_',
329 'aioseo' => null, // Custom table
330 ];
331
332 $prefix = $prefix_map[$plugin] ?? null;
333
334 if ($prefix) {
335 // Delete from postmeta
336 $deleted += (int) $wpdb->query(
337 $wpdb->prepare(
338 "DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE %s",
339 $wpdb->esc_like($prefix) . '%'
340 )
341 );
342
343 // Delete from termmeta
344 $deleted += (int) $wpdb->query(
345 $wpdb->prepare(
346 "DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
347 $wpdb->esc_like($prefix) . '%'
348 )
349 );
350
351 // Delete author-level SEO meta from usermeta. Yoast keys user meta
352 // under a different prefix than its post meta, so map it explicitly;
353 // the others reuse their post-meta prefix.
354 $usermeta_prefix_map = [
355 'yoast' => 'wpseo_',
356 'rankmath' => 'rank_math_',
357 'seopress' => '_seopress_',
358 ];
359 $usermeta_prefix = $usermeta_prefix_map[$plugin] ?? $prefix;
360 $deleted += (int) $wpdb->query(
361 $wpdb->prepare(
362 "DELETE FROM {$wpdb->usermeta} WHERE meta_key LIKE %s",
363 $wpdb->esc_like($usermeta_prefix) . '%'
364 )
365 );
366
367 // Delete the source plugin's option rows — the exact options each
368 // exporter reads, so the site-level settings we migrated are removed
369 // too rather than left orphaned.
370 $option_keys_map = [
371 'yoast' => ['wpseo', 'wpseo_titles', 'wpseo_social', 'wpseo_taxonomy_meta'],
372 'rankmath' => ['rank-math-options-general', 'rank-math-options-titles', 'rank-math-options-sitemap', 'rank-math-options-instant-indexing'],
373 'seopress' => ['seopress_titles_option_name', 'seopress_social_option_name', 'seopress_advanced_option_name', 'seopress_xml_sitemap_option_name', 'seopress_instant_indexing_option_name'],
374 ];
375 foreach ($option_keys_map[$plugin] ?? [] as $option_name) {
376 if (delete_option($option_name)) {
377 $deleted++;
378 }
379 }
380 }
381
382 if ($plugin === 'aioseo') {
383 // Drop the AIOSEO custom tables the exporter reads (posts + Pro terms).
384 foreach (['aioseo_posts', 'aioseo_terms'] as $suffix) {
385 $table = $wpdb->prefix . $suffix;
386 $table_exists = $wpdb->get_var(
387 $wpdb->prepare("SHOW TABLES LIKE %s", $table)
388 );
389 if ($table_exists) {
390 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
391 $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
392 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
393 $wpdb->query("DROP TABLE {$table}");
394 $deleted += $count;
395 }
396 }
397
398 // Legacy term meta (aioseo_*-prefixed) some versions used.
399 $deleted += (int) $wpdb->query(
400 $wpdb->prepare(
401 "DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
402 $wpdb->esc_like('aioseo_') . '%'
403 )
404 );
405
406 // The option blobs the exporter reads (settings, dynamic per-type
407 // templates, Pro add-on settings).
408 foreach (['aioseo_options', 'aioseo_options_dynamic', 'aioseo_options_pro'] as $option_name) {
409 if (delete_option($option_name)) {
410 $deleted++;
411 }
412 }
413 }
414
415 // Clear detection cache
416 $detector = new Import_Detector();
417 $detector->clear_cache();
418
419 return new \WP_REST_Response([
420 'status' => 'complete',
421 'message' => sprintf('Deleted %d source data entries for %s', $deleted, $plugin),
422 'deleted' => $deleted,
423 ], 200);
424 }
425
426 /**
427 * DELETE /import/snapshot — Delete snapshot data from wp_options
428 *
429 * @param \WP_REST_Request $request Request object
430 * @return \WP_REST_Response
431 */
432 public function delete_snapshot(\WP_REST_Request $request): \WP_REST_Response {
433 $plugin = $request->get_param('plugin');
434 $deleted = Snapshot_Store::delete_snapshot($plugin);
435
436 return new \WP_REST_Response([
437 'status' => 'complete',
438 'message' => sprintf('Deleted %d snapshot options for %s', $deleted, $plugin),
439 'deleted' => $deleted,
440 ], 200);
441 }
442
443 /**
444 * Get the appropriate exporter instance for a plugin
445 *
446 * @param string $plugin Plugin slug
447 * @return Abstract_Plugin_Exporter|\WP_Error
448 */
449 private function get_exporter(string $plugin) {
450 switch ($plugin) {
451 case 'yoast':
452 return new Yoast_Exporter();
453 case 'rankmath':
454 return new Rankmath_Exporter();
455 case 'seopress':
456 return new SEOPress_Exporter();
457 case 'aioseo':
458 return new AIOSEO_Exporter();
459 case Thinkrank_Exporter::SLUG:
460 return new Thinkrank_Exporter();
461 default:
462 return new \WP_Error('invalid_plugin', 'Unsupported plugin: ' . $plugin, ['status' => 400]);
463 }
464 }
465
466 /**
467 * Get argument schema for export endpoint
468 *
469 * @return array
470 */
471 private function get_export_args(): array {
472 return [
473 'plugin' => [
474 'required' => true,
475 'type' => 'string',
476 'enum' => self::ALLOWED_PLUGINS,
477 'sanitize_callback' => 'sanitize_text_field',
478 ],
479 'type' => [
480 'required' => true,
481 'type' => 'string',
482 'enum' => $this->get_allowed_types(),
483 'sanitize_callback' => 'sanitize_text_field',
484 ],
485 'page' => [
486 'required' => true,
487 'type' => 'integer',
488 'minimum' => 1,
489 'sanitize_callback' => 'absint',
490 ],
491 'is_last_type' => [
492 'required' => false,
493 'type' => 'boolean',
494 'default' => false,
495 ],
496 // Set on the first chunk of a run to discard whatever is already in
497 // the snapshot slot. Without it a run inherits the previous one's
498 // types: the manifest is merged into, never replaced, so a type the
499 // user deselected (or a file they uploaded and chose not to
500 // restore) stays in the snapshot and is streamed by
501 // /export/download, which sends every type the manifest lists.
502 'reset' => [
503 'required' => false,
504 'type' => 'boolean',
505 'default' => false,
506 ],
507 ];
508 }
509
510 /**
511 * Get argument schema for migrate endpoint
512 *
513 * @return array
514 */
515 private function get_migrate_args(): array {
516 return [
517 'plugin' => [
518 'required' => true,
519 'type' => 'string',
520 'enum' => self::ALLOWED_PLUGINS,
521 'sanitize_callback' => 'sanitize_text_field',
522 ],
523 // Defaults to skip, which is the safe answer for an import from
524 // another plugin: its data must never clobber something already
525 // set here. A restore from a ThinkRank backup passes overwrite —
526 // getting the saved values back is the entire point of it.
527 'conflict' => [
528 'required' => false,
529 'type' => 'string',
530 'enum' => [Snapshot_Migrator::CONFLICT_SKIP, Snapshot_Migrator::CONFLICT_OVERWRITE],
531 'default' => Snapshot_Migrator::CONFLICT_SKIP,
532 'sanitize_callback' => 'sanitize_text_field',
533 ],
534 'type' => [
535 'required' => true,
536 'type' => 'string',
537 'enum' => $this->get_allowed_types(),
538 'sanitize_callback' => 'sanitize_text_field',
539 ],
540 'page' => [
541 'required' => true,
542 'type' => 'integer',
543 'minimum' => 1,
544 'sanitize_callback' => 'absint',
545 ],
546 'is_last_type' => [
547 'required' => false,
548 'type' => 'boolean',
549 'default' => false,
550 ],
551 ];
552 }
553 }
554