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

594 lines 23.3 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', 'squirrly'];
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', 'squirrly', '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 'args' => [
94 'refresh' => [
95 'type' => 'boolean',
96 'default' => false,
97 'sanitize_callback' => 'rest_sanitize_boolean',
98 'description' => __('Discard the cached scan and re-read the database. Set by the Re-scan button; the screen\'s own load leaves it off so repeat navigation stays instant.', 'thinkrank'),
99 ],
100 ],
101 ],
102 ]);
103
104 register_rest_route($this->namespace, '/' . $this->rest_base . '/export', [
105 [
106 'methods' => \WP_REST_Server::CREATABLE,
107 'callback' => [$this, 'export'],
108 'permission_callback' => [$this, 'check_permissions'],
109 'args' => $this->get_export_args(),
110 ],
111 ]);
112
113 register_rest_route($this->namespace, '/' . $this->rest_base . '/snapshots', [
114 [
115 'methods' => \WP_REST_Server::READABLE,
116 'callback' => [$this, 'get_snapshots'],
117 'permission_callback' => [$this, 'check_permissions'],
118 ],
119 ]);
120
121 register_rest_route($this->namespace, '/' . $this->rest_base . '/migrate', [
122 [
123 'methods' => \WP_REST_Server::CREATABLE,
124 'callback' => [$this, 'migrate'],
125 'permission_callback' => [$this, 'check_permissions'],
126 'args' => $this->get_migrate_args(),
127 ],
128 ]);
129
130 register_rest_route($this->namespace, '/' . $this->rest_base . '/cleanup', [
131 [
132 'methods' => \WP_REST_Server::CREATABLE,
133 'callback' => [$this, 'cleanup'],
134 'permission_callback' => [$this, 'check_permissions'],
135 'args' => [
136 'plugin' => [
137 'required' => true,
138 'type' => 'string',
139 // Source plugins only — see SOURCE_PLUGINS.
140 'enum' => self::SOURCE_PLUGINS,
141 'sanitize_callback' => 'sanitize_text_field',
142 ],
143 // Required to proceed while the snapshot still holds
144 // extended data with no migration path (see cleanup()).
145 'force' => [
146 'required' => false,
147 'type' => 'boolean',
148 'default' => false,
149 ],
150 ],
151 ],
152 ]);
153
154 register_rest_route($this->namespace, '/' . $this->rest_base . '/snapshot', [
155 [
156 'methods' => \WP_REST_Server::DELETABLE,
157 'callback' => [$this, 'delete_snapshot'],
158 'permission_callback' => [$this, 'check_permissions'],
159 'args' => [
160 'plugin' => [
161 'required' => true,
162 'type' => 'string',
163 'enum' => self::ALLOWED_PLUGINS,
164 'sanitize_callback' => 'sanitize_text_field',
165 ],
166 ],
167 ],
168 ]);
169 }
170
171 /**
172 * Permission check — manage_options required
173 *
174 * @return bool
175 */
176 public function check_permissions(): bool {
177 return current_user_can('manage_options');
178 }
179
180 /**
181 * GET /import/detect — Detect source plugins, ThinkRank's own exportable
182 * data, and existing snapshots
183 *
184 * `refresh` drops the hour-long detection transient before scanning. Without
185 * it the Re-scan button could not do the one thing it exists for: pick up a
186 * source plugin the user just installed, activated or added data to.
187 *
188 * @param \WP_REST_Request $request Request object
189 * @return \WP_REST_Response
190 */
191 public function detect(\WP_REST_Request $request): \WP_REST_Response {
192 $detector = new Import_Detector();
193
194 if ($request->get_param('refresh')) {
195 $detector->clear_cache();
196 }
197
198 $detected = $detector->detect();
199 $snapshots = Snapshot_Store::get_available_snapshots();
200
201 return new \WP_REST_Response([
202 'detected' => $detected,
203 // ThinkRank's own exportable data, reported separately from the
204 // source plugins the user can migrate FROM.
205 'native' => $detector->detect_native(),
206 'snapshots' => $snapshots,
207 ], 200);
208 }
209
210 /**
211 * POST /import/export — Export a batch of source data to snapshot
212 *
213 * @param \WP_REST_Request $request Request object
214 * @return \WP_REST_Response|\WP_Error
215 */
216 public function export(\WP_REST_Request $request) {
217 $plugin = $request->get_param('plugin');
218 $type = $request->get_param('type');
219 $page = (int) $request->get_param('page');
220
221 $exporter = $this->get_exporter($plugin);
222 if (is_wp_error($exporter)) {
223 return $exporter;
224 }
225
226 // Start a run from an empty slot. update_manifest() merges into whatever
227 // manifest is already there, so without this the file the user ends up
228 // downloading carries the union of this run and every run before it.
229 if ((bool) $request->get_param('reset')) {
230 Snapshot_Store::delete_snapshot($plugin);
231 }
232
233 $result = $exporter->export_chunk($type, $page);
234
235 // If this type is complete and it's the last type, finalize
236 if (!$result['has_more'] && $request->get_param('is_last_type')) {
237 $exporter->finalize_export();
238 }
239
240 return new \WP_REST_Response($result, 200);
241 }
242
243 /**
244 * GET /import/snapshots — List existing snapshots
245 *
246 * @param \WP_REST_Request $request Request object
247 * @return \WP_REST_Response
248 */
249 public function get_snapshots(\WP_REST_Request $request): \WP_REST_Response {
250 $snapshots = Snapshot_Store::get_available_snapshots();
251 return new \WP_REST_Response(['snapshots' => $snapshots], 200);
252 }
253
254 /**
255 * POST /import/migrate — Migrate a batch from snapshot to ThinkRank meta,
256 * or restore one from ThinkRank's own export
257 *
258 * @param \WP_REST_Request $request Request object
259 * @return \WP_REST_Response
260 */
261 public function migrate(\WP_REST_Request $request): \WP_REST_Response {
262 $plugin = $request->get_param('plugin');
263 $type = $request->get_param('type');
264 $page = (int) $request->get_param('page');
265 $conflict = (string) $request->get_param('conflict');
266
267 $migrator = new Snapshot_Migrator();
268 $result = $migrator->migrate_chunk($plugin, $type, $page, $conflict);
269
270 // If migration is complete for all types, update manifest
271 if (!$result['has_more'] && $request->get_param('is_last_type')) {
272 $migrator->update_manifest_migration_info($plugin);
273 }
274
275 return new \WP_REST_Response($result, 200);
276 }
277
278 /**
279 * POST /import/cleanup — Remove source plugin meta from database
280 *
281 * Deletes SOURCE plugin data only — the ThinkRank snapshot is never touched
282 * by this endpoint (use DELETE /import/snapshot for that). Because the
283 * snapshot may still hold extended data ThinkRank cannot apply yet (e.g.
284 * redirections, owed to the Pro Redirections feature), cleanup is gated:
285 * while such buckets exist the request is rejected with HTTP 409 unless
286 * force=true is passed, so the user explicitly acknowledges that the
287 * snapshot becomes the only copy of that data.
288 *
289 * @param \WP_REST_Request $request Request object
290 * @return \WP_REST_Response|\WP_Error
291 */
292 public function cleanup(\WP_REST_Request $request) {
293 global $wpdb;
294
295 $plugin = $request->get_param('plugin');
296 $force = (bool) $request->get_param('force');
297 $deleted = 0;
298
299 // Belt and braces on top of the route's SOURCE_PLUGINS enum: this
300 // endpoint deletes the SOURCE plugin's live meta and options, so
301 // pointing it at ThinkRank would delete the user's own SEO data.
302 if ($plugin === self::NATIVE_PLUGIN) {
303 return new \WP_Error(
304 'thinkrank_cleanup_not_applicable',
305 __('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'),
306 ['status' => 400]
307 );
308 }
309
310 // Gate: block while the snapshot holds preserved-but-unapplied extended
311 // data, unless the caller explicitly forces the cleanup.
312 if (!$force) {
313 $migrator = new Snapshot_Migrator();
314 $unmigrated = $migrator->get_unmigrated_extended_buckets($plugin);
315
316 if (!empty($unmigrated)) {
317 $labels = array_map(
318 static function (array $bucket): string {
319 return $bucket['count'] > 1
320 ? sprintf('%s (%d)', $bucket['label'], $bucket['count'])
321 : (string) $bucket['label'];
322 },
323 $unmigrated
324 );
325
326 return new \WP_Error(
327 'thinkrank_cleanup_blocked',
328 sprintf(
329 /* translators: %s: comma-separated list of unapplied data buckets. */
330 __('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'),
331 implode(', ', $labels)
332 ),
333 [
334 'status' => 409,
335 'preserved' => $unmigrated,
336 'requires_force' => true,
337 ]
338 );
339 }
340 }
341
342 $prefix_map = [
343 'yoast' => '_yoast_wpseo_',
344 'rankmath' => 'rank_math_',
345 'seopress' => '_seopress_',
346 'aioseo' => null, // Custom table
347 'squirrly' => '_sq_', // Fallback meta only; the SEO is in the qss table below
348 ];
349
350 $prefix = $prefix_map[$plugin] ?? null;
351
352 if ($prefix) {
353 // Delete from postmeta
354 $deleted += (int) $wpdb->query(
355 $wpdb->prepare(
356 "DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE %s",
357 $wpdb->esc_like($prefix) . '%'
358 )
359 );
360
361 // Delete from termmeta
362 $deleted += (int) $wpdb->query(
363 $wpdb->prepare(
364 "DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
365 $wpdb->esc_like($prefix) . '%'
366 )
367 );
368
369 // Delete author-level SEO meta from usermeta. Yoast keys user meta
370 // under a different prefix than its post meta, so map it explicitly;
371 // the others reuse their post-meta prefix.
372 $usermeta_prefix_map = [
373 'yoast' => 'wpseo_',
374 'rankmath' => 'rank_math_',
375 'seopress' => '_seopress_',
376 'squirrly' => '_sq_',
377 ];
378 $usermeta_prefix = $usermeta_prefix_map[$plugin] ?? $prefix;
379 $deleted += (int) $wpdb->query(
380 $wpdb->prepare(
381 "DELETE FROM {$wpdb->usermeta} WHERE meta_key LIKE %s",
382 $wpdb->esc_like($usermeta_prefix) . '%'
383 )
384 );
385
386 // Delete the source plugin's option rows — the exact options each
387 // exporter reads, so the site-level settings we migrated are removed
388 // too rather than left orphaned.
389 $option_keys_map = [
390 'yoast' => ['wpseo', 'wpseo_titles', 'wpseo_social', 'wpseo_taxonomy_meta'],
391 'rankmath' => ['rank-math-options-general', 'rank-math-options-titles', 'rank-math-options-sitemap', 'rank-math-options-instant-indexing'],
392 'seopress' => ['seopress_titles_option_name', 'seopress_social_option_name', 'seopress_advanced_option_name', 'seopress_xml_sitemap_option_name', 'seopress_instant_indexing_option_name'],
393 'squirrly' => ['sq_options'],
394 ];
395 foreach ($option_keys_map[$plugin] ?? [] as $option_name) {
396 if (delete_option($option_name)) {
397 $deleted++;
398 }
399 }
400 }
401
402 if ($plugin === 'squirrly') {
403 // Drop Squirrly's custom tables: per-URL SEO, Advanced Pack
404 // redirects and their logs, reusable JSON-LD templates.
405 foreach (['qss', 'qss_redirects', 'qss_redirects_logs', 'qss_jsonld'] as $suffix) {
406 $table = $wpdb->prefix . $suffix;
407 $table_exists = $wpdb->get_var(
408 $wpdb->prepare("SHOW TABLES LIKE %s", $table)
409 );
410 if ($table_exists) {
411 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
412 $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
413 // 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.
414 $wpdb->query("DROP TABLE {$table}");
415 $deleted += $count;
416 }
417 }
418 }
419
420 if ($plugin === 'aioseo') {
421 // Drop the AIOSEO custom tables the exporter reads (posts + Pro terms).
422 foreach (['aioseo_posts', 'aioseo_terms'] as $suffix) {
423 $table = $wpdb->prefix . $suffix;
424 $table_exists = $wpdb->get_var(
425 $wpdb->prepare("SHOW TABLES LIKE %s", $table)
426 );
427 if ($table_exists) {
428 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
429 $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
430 // 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.
431 $wpdb->query("DROP TABLE {$table}");
432 $deleted += $count;
433 }
434 }
435
436 // Legacy term meta (aioseo_*-prefixed) some versions used.
437 $deleted += (int) $wpdb->query(
438 $wpdb->prepare(
439 "DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
440 $wpdb->esc_like('aioseo_') . '%'
441 )
442 );
443
444 // The option blobs the exporter reads (settings, dynamic per-type
445 // templates, Pro add-on settings).
446 foreach (['aioseo_options', 'aioseo_options_dynamic', 'aioseo_options_pro'] as $option_name) {
447 if (delete_option($option_name)) {
448 $deleted++;
449 }
450 }
451 }
452
453 // Clear detection cache
454 $detector = new Import_Detector();
455 $detector->clear_cache();
456
457 return new \WP_REST_Response([
458 'status' => 'complete',
459 'message' => sprintf('Deleted %d source data entries for %s', $deleted, $plugin),
460 'deleted' => $deleted,
461 ], 200);
462 }
463
464 /**
465 * DELETE /import/snapshot — Delete snapshot data from wp_options
466 *
467 * @param \WP_REST_Request $request Request object
468 * @return \WP_REST_Response
469 */
470 public function delete_snapshot(\WP_REST_Request $request): \WP_REST_Response {
471 $plugin = $request->get_param('plugin');
472 $deleted = Snapshot_Store::delete_snapshot($plugin);
473
474 return new \WP_REST_Response([
475 'status' => 'complete',
476 'message' => sprintf('Deleted %d snapshot options for %s', $deleted, $plugin),
477 'deleted' => $deleted,
478 ], 200);
479 }
480
481 /**
482 * Get the appropriate exporter instance for a plugin
483 *
484 * @param string $plugin Plugin slug
485 * @return Abstract_Plugin_Exporter|\WP_Error
486 */
487 private function get_exporter(string $plugin) {
488 switch ($plugin) {
489 case 'yoast':
490 return new Yoast_Exporter();
491 case 'rankmath':
492 return new Rankmath_Exporter();
493 case 'seopress':
494 return new SEOPress_Exporter();
495 case 'aioseo':
496 return new AIOSEO_Exporter();
497 case Squirrly_Exporter::SLUG:
498 return new Squirrly_Exporter();
499 case Thinkrank_Exporter::SLUG:
500 return new Thinkrank_Exporter();
501 default:
502 return new \WP_Error('invalid_plugin', 'Unsupported plugin: ' . $plugin, ['status' => 400]);
503 }
504 }
505
506 /**
507 * Get argument schema for export endpoint
508 *
509 * @return array
510 */
511 private function get_export_args(): array {
512 return [
513 'plugin' => [
514 'required' => true,
515 'type' => 'string',
516 'enum' => self::ALLOWED_PLUGINS,
517 'sanitize_callback' => 'sanitize_text_field',
518 ],
519 'type' => [
520 'required' => true,
521 'type' => 'string',
522 'enum' => $this->get_allowed_types(),
523 'sanitize_callback' => 'sanitize_text_field',
524 ],
525 'page' => [
526 'required' => true,
527 'type' => 'integer',
528 'minimum' => 1,
529 'sanitize_callback' => 'absint',
530 ],
531 'is_last_type' => [
532 'required' => false,
533 'type' => 'boolean',
534 'default' => false,
535 ],
536 // Set on the first chunk of a run to discard whatever is already in
537 // the snapshot slot. Without it a run inherits the previous one's
538 // types: the manifest is merged into, never replaced, so a type the
539 // user deselected (or a file they uploaded and chose not to
540 // restore) stays in the snapshot and is streamed by
541 // /export/download, which sends every type the manifest lists.
542 'reset' => [
543 'required' => false,
544 'type' => 'boolean',
545 'default' => false,
546 ],
547 ];
548 }
549
550 /**
551 * Get argument schema for migrate endpoint
552 *
553 * @return array
554 */
555 private function get_migrate_args(): array {
556 return [
557 'plugin' => [
558 'required' => true,
559 'type' => 'string',
560 'enum' => self::ALLOWED_PLUGINS,
561 'sanitize_callback' => 'sanitize_text_field',
562 ],
563 // Defaults to skip, which is the safe answer for an import from
564 // another plugin: its data must never clobber something already
565 // set here. A restore from a ThinkRank backup passes overwrite —
566 // getting the saved values back is the entire point of it.
567 'conflict' => [
568 'required' => false,
569 'type' => 'string',
570 'enum' => [Snapshot_Migrator::CONFLICT_SKIP, Snapshot_Migrator::CONFLICT_OVERWRITE],
571 'default' => Snapshot_Migrator::CONFLICT_SKIP,
572 'sanitize_callback' => 'sanitize_text_field',
573 ],
574 'type' => [
575 'required' => true,
576 'type' => 'string',
577 'enum' => $this->get_allowed_types(),
578 'sanitize_callback' => 'sanitize_text_field',
579 ],
580 'page' => [
581 'required' => true,
582 'type' => 'integer',
583 'minimum' => 1,
584 'sanitize_callback' => 'absint',
585 ],
586 'is_last_type' => [
587 'required' => false,
588 'type' => 'boolean',
589 'default' => false,
590 ],
591 ];
592 }
593 }
594