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

474 lines 17.2 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 * Allowed plugin slugs
40 */
41 private const ALLOWED_PLUGINS = ['yoast', 'rankmath', 'seopress', 'aioseo'];
42
43 /**
44 * Allowed export/migrate types. Must cover every type the exporters and
45 * the frontend workflow (useImportWorkflow.js EXPORT_TYPES) can send —
46 * 404_logs was missing, so Rank Math's 404 Monitor could never be
47 * exported or migrated through REST.
48 */
49 private const ALLOWED_TYPES = ['postmeta', 'termmeta', 'usermeta', 'redirections', '404_logs', 'settings'];
50
51 /**
52 * Register REST routes
53 *
54 * @return void
55 */
56 public function register_routes(): void {
57 register_rest_route($this->namespace, '/' . $this->rest_base . '/detect', [
58 [
59 'methods' => \WP_REST_Server::READABLE,
60 'callback' => [$this, 'detect'],
61 'permission_callback' => [$this, 'check_permissions'],
62 ],
63 ]);
64
65 register_rest_route($this->namespace, '/' . $this->rest_base . '/export', [
66 [
67 'methods' => \WP_REST_Server::CREATABLE,
68 'callback' => [$this, 'export'],
69 'permission_callback' => [$this, 'check_permissions'],
70 'args' => $this->get_export_args(),
71 ],
72 ]);
73
74 register_rest_route($this->namespace, '/' . $this->rest_base . '/snapshots', [
75 [
76 'methods' => \WP_REST_Server::READABLE,
77 'callback' => [$this, 'get_snapshots'],
78 'permission_callback' => [$this, 'check_permissions'],
79 ],
80 ]);
81
82 register_rest_route($this->namespace, '/' . $this->rest_base . '/migrate', [
83 [
84 'methods' => \WP_REST_Server::CREATABLE,
85 'callback' => [$this, 'migrate'],
86 'permission_callback' => [$this, 'check_permissions'],
87 'args' => $this->get_migrate_args(),
88 ],
89 ]);
90
91 register_rest_route($this->namespace, '/' . $this->rest_base . '/cleanup', [
92 [
93 'methods' => \WP_REST_Server::CREATABLE,
94 'callback' => [$this, 'cleanup'],
95 'permission_callback' => [$this, 'check_permissions'],
96 'args' => [
97 'plugin' => [
98 'required' => true,
99 'type' => 'string',
100 'enum' => self::ALLOWED_PLUGINS,
101 'sanitize_callback' => 'sanitize_text_field',
102 ],
103 // Required to proceed while the snapshot still holds
104 // extended data with no migration path (see cleanup()).
105 'force' => [
106 'required' => false,
107 'type' => 'boolean',
108 'default' => false,
109 ],
110 ],
111 ],
112 ]);
113
114 register_rest_route($this->namespace, '/' . $this->rest_base . '/snapshot', [
115 [
116 'methods' => \WP_REST_Server::DELETABLE,
117 'callback' => [$this, 'delete_snapshot'],
118 'permission_callback' => [$this, 'check_permissions'],
119 'args' => [
120 'plugin' => [
121 'required' => true,
122 'type' => 'string',
123 'enum' => self::ALLOWED_PLUGINS,
124 'sanitize_callback' => 'sanitize_text_field',
125 ],
126 ],
127 ],
128 ]);
129 }
130
131 /**
132 * Permission check — manage_options required
133 *
134 * @return bool
135 */
136 public function check_permissions(): bool {
137 return current_user_can('manage_options');
138 }
139
140 /**
141 * GET /import/detect — Detect source plugins and existing snapshots
142 *
143 * @param \WP_REST_Request $request Request object
144 * @return \WP_REST_Response
145 */
146 public function detect(\WP_REST_Request $request): \WP_REST_Response {
147 $detector = new Import_Detector();
148 $detected = $detector->detect();
149 $snapshots = Snapshot_Store::get_available_snapshots();
150
151 return new \WP_REST_Response([
152 'detected' => $detected,
153 'snapshots' => $snapshots,
154 ], 200);
155 }
156
157 /**
158 * POST /import/export — Export a batch of source data to snapshot
159 *
160 * @param \WP_REST_Request $request Request object
161 * @return \WP_REST_Response|\WP_Error
162 */
163 public function export(\WP_REST_Request $request) {
164 $plugin = $request->get_param('plugin');
165 $type = $request->get_param('type');
166 $page = (int) $request->get_param('page');
167
168 $exporter = $this->get_exporter($plugin);
169 if (is_wp_error($exporter)) {
170 return $exporter;
171 }
172
173 $result = $exporter->export_chunk($type, $page);
174
175 // If this type is complete and it's the last type, finalize
176 if (!$result['has_more'] && $request->get_param('is_last_type')) {
177 $exporter->finalize_export();
178 }
179
180 return new \WP_REST_Response($result, 200);
181 }
182
183 /**
184 * GET /import/snapshots — List existing snapshots
185 *
186 * @param \WP_REST_Request $request Request object
187 * @return \WP_REST_Response
188 */
189 public function get_snapshots(\WP_REST_Request $request): \WP_REST_Response {
190 $snapshots = Snapshot_Store::get_available_snapshots();
191 return new \WP_REST_Response(['snapshots' => $snapshots], 200);
192 }
193
194 /**
195 * POST /import/migrate — Migrate a batch from snapshot to ThinkRank meta
196 *
197 * @param \WP_REST_Request $request Request object
198 * @return \WP_REST_Response
199 */
200 public function migrate(\WP_REST_Request $request): \WP_REST_Response {
201 $plugin = $request->get_param('plugin');
202 $type = $request->get_param('type');
203 $page = (int) $request->get_param('page');
204
205 $migrator = new Snapshot_Migrator();
206 $result = $migrator->migrate_chunk($plugin, $type, $page);
207
208 // If migration is complete for all types, update manifest
209 if (!$result['has_more'] && $request->get_param('is_last_type')) {
210 $migrator->update_manifest_migration_info($plugin);
211 }
212
213 return new \WP_REST_Response($result, 200);
214 }
215
216 /**
217 * POST /import/cleanup — Remove source plugin meta from database
218 *
219 * Deletes SOURCE plugin data only — the ThinkRank snapshot is never touched
220 * by this endpoint (use DELETE /import/snapshot for that). Because the
221 * snapshot may still hold extended data ThinkRank cannot apply yet (e.g.
222 * redirections, owed to the Pro Redirections feature), cleanup is gated:
223 * while such buckets exist the request is rejected with HTTP 409 unless
224 * force=true is passed, so the user explicitly acknowledges that the
225 * snapshot becomes the only copy of that data.
226 *
227 * @param \WP_REST_Request $request Request object
228 * @return \WP_REST_Response|\WP_Error
229 */
230 public function cleanup(\WP_REST_Request $request) {
231 global $wpdb;
232
233 $plugin = $request->get_param('plugin');
234 $force = (bool) $request->get_param('force');
235 $deleted = 0;
236
237 // Gate: block while the snapshot holds preserved-but-unapplied extended
238 // data, unless the caller explicitly forces the cleanup.
239 if (!$force) {
240 $migrator = new Snapshot_Migrator();
241 $unmigrated = $migrator->get_unmigrated_extended_buckets($plugin);
242
243 if (!empty($unmigrated)) {
244 $labels = array_map(
245 static function (array $bucket): string {
246 return $bucket['count'] > 1
247 ? sprintf('%s (%d)', $bucket['label'], $bucket['count'])
248 : (string) $bucket['label'];
249 },
250 $unmigrated
251 );
252
253 return new \WP_Error(
254 'thinkrank_cleanup_blocked',
255 sprintf(
256 /* translators: %s: comma-separated list of unapplied data buckets. */
257 __('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'),
258 implode(', ', $labels)
259 ),
260 [
261 'status' => 409,
262 'preserved' => $unmigrated,
263 'requires_force' => true,
264 ]
265 );
266 }
267 }
268
269 $prefix_map = [
270 'yoast' => '_yoast_wpseo_',
271 'rankmath' => 'rank_math_',
272 'seopress' => '_seopress_',
273 'aioseo' => null, // Custom table
274 ];
275
276 $prefix = $prefix_map[$plugin] ?? null;
277
278 if ($prefix) {
279 // Delete from postmeta
280 $deleted += (int) $wpdb->query(
281 $wpdb->prepare(
282 "DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE %s",
283 $wpdb->esc_like($prefix) . '%'
284 )
285 );
286
287 // Delete from termmeta
288 $deleted += (int) $wpdb->query(
289 $wpdb->prepare(
290 "DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
291 $wpdb->esc_like($prefix) . '%'
292 )
293 );
294
295 // Delete author-level SEO meta from usermeta. Yoast keys user meta
296 // under a different prefix than its post meta, so map it explicitly;
297 // the others reuse their post-meta prefix.
298 $usermeta_prefix_map = [
299 'yoast' => 'wpseo_',
300 'rankmath' => 'rank_math_',
301 'seopress' => '_seopress_',
302 ];
303 $usermeta_prefix = $usermeta_prefix_map[$plugin] ?? $prefix;
304 $deleted += (int) $wpdb->query(
305 $wpdb->prepare(
306 "DELETE FROM {$wpdb->usermeta} WHERE meta_key LIKE %s",
307 $wpdb->esc_like($usermeta_prefix) . '%'
308 )
309 );
310
311 // Delete the source plugin's option rows — the exact options each
312 // exporter reads, so the site-level settings we migrated are removed
313 // too rather than left orphaned.
314 $option_keys_map = [
315 'yoast' => ['wpseo', 'wpseo_titles', 'wpseo_social', 'wpseo_taxonomy_meta'],
316 'rankmath' => ['rank-math-options-general', 'rank-math-options-titles', 'rank-math-options-sitemap', 'rank-math-options-instant-indexing'],
317 'seopress' => ['seopress_titles_option_name', 'seopress_social_option_name', 'seopress_advanced_option_name', 'seopress_xml_sitemap_option_name', 'seopress_instant_indexing_option_name'],
318 ];
319 foreach ($option_keys_map[$plugin] ?? [] as $option_name) {
320 if (delete_option($option_name)) {
321 $deleted++;
322 }
323 }
324 }
325
326 if ($plugin === 'aioseo') {
327 // Drop the AIOSEO custom tables the exporter reads (posts + Pro terms).
328 foreach (['aioseo_posts', 'aioseo_terms'] as $suffix) {
329 $table = $wpdb->prefix . $suffix;
330 $table_exists = $wpdb->get_var(
331 $wpdb->prepare("SHOW TABLES LIKE %s", $table)
332 );
333 if ($table_exists) {
334 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
335 $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
336 // 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.
337 $wpdb->query("DROP TABLE {$table}");
338 $deleted += $count;
339 }
340 }
341
342 // Legacy term meta (aioseo_*-prefixed) some versions used.
343 $deleted += (int) $wpdb->query(
344 $wpdb->prepare(
345 "DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
346 $wpdb->esc_like('aioseo_') . '%'
347 )
348 );
349
350 // The option blobs the exporter reads (settings, dynamic per-type
351 // templates, Pro add-on settings).
352 foreach (['aioseo_options', 'aioseo_options_dynamic', 'aioseo_options_pro'] as $option_name) {
353 if (delete_option($option_name)) {
354 $deleted++;
355 }
356 }
357 }
358
359 // Clear detection cache
360 $detector = new Import_Detector();
361 $detector->clear_cache();
362
363 return new \WP_REST_Response([
364 'status' => 'complete',
365 'message' => sprintf('Deleted %d source data entries for %s', $deleted, $plugin),
366 'deleted' => $deleted,
367 ], 200);
368 }
369
370 /**
371 * DELETE /import/snapshot — Delete snapshot data from wp_options
372 *
373 * @param \WP_REST_Request $request Request object
374 * @return \WP_REST_Response
375 */
376 public function delete_snapshot(\WP_REST_Request $request): \WP_REST_Response {
377 $plugin = $request->get_param('plugin');
378 $deleted = Snapshot_Store::delete_snapshot($plugin);
379
380 return new \WP_REST_Response([
381 'status' => 'complete',
382 'message' => sprintf('Deleted %d snapshot options for %s', $deleted, $plugin),
383 'deleted' => $deleted,
384 ], 200);
385 }
386
387 /**
388 * Get the appropriate exporter instance for a plugin
389 *
390 * @param string $plugin Plugin slug
391 * @return Abstract_Plugin_Exporter|\WP_Error
392 */
393 private function get_exporter(string $plugin) {
394 switch ($plugin) {
395 case 'yoast':
396 return new Yoast_Exporter();
397 case 'rankmath':
398 return new Rankmath_Exporter();
399 case 'seopress':
400 return new SEOPress_Exporter();
401 case 'aioseo':
402 return new AIOSEO_Exporter();
403 default:
404 return new \WP_Error('invalid_plugin', 'Unsupported plugin: ' . $plugin, ['status' => 400]);
405 }
406 }
407
408 /**
409 * Get argument schema for export endpoint
410 *
411 * @return array
412 */
413 private function get_export_args(): array {
414 return [
415 'plugin' => [
416 'required' => true,
417 'type' => 'string',
418 'enum' => self::ALLOWED_PLUGINS,
419 'sanitize_callback' => 'sanitize_text_field',
420 ],
421 'type' => [
422 'required' => true,
423 'type' => 'string',
424 'enum' => self::ALLOWED_TYPES,
425 'sanitize_callback' => 'sanitize_text_field',
426 ],
427 'page' => [
428 'required' => true,
429 'type' => 'integer',
430 'minimum' => 1,
431 'sanitize_callback' => 'absint',
432 ],
433 'is_last_type' => [
434 'required' => false,
435 'type' => 'boolean',
436 'default' => false,
437 ],
438 ];
439 }
440
441 /**
442 * Get argument schema for migrate endpoint
443 *
444 * @return array
445 */
446 private function get_migrate_args(): array {
447 return [
448 'plugin' => [
449 'required' => true,
450 'type' => 'string',
451 'enum' => self::ALLOWED_PLUGINS,
452 'sanitize_callback' => 'sanitize_text_field',
453 ],
454 'type' => [
455 'required' => true,
456 'type' => 'string',
457 'enum' => self::ALLOWED_TYPES,
458 'sanitize_callback' => 'sanitize_text_field',
459 ],
460 'page' => [
461 'required' => true,
462 'type' => 'integer',
463 'minimum' => 1,
464 'sanitize_callback' => 'absint',
465 ],
466 'is_last_type' => [
467 'required' => false,
468 'type' => 'boolean',
469 'default' => false,
470 ],
471 ];
472 }
473 }
474