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

468 lines 16.7 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 $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
335 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
336 $wpdb->query("DROP TABLE {$table}");
337 $deleted += $count;
338 }
339 }
340
341 // Legacy term meta (aioseo_*-prefixed) some versions used.
342 $deleted += (int) $wpdb->query(
343 $wpdb->prepare(
344 "DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
345 $wpdb->esc_like('aioseo_') . '%'
346 )
347 );
348
349 // The option blobs the exporter reads (settings, dynamic per-type
350 // templates, Pro add-on settings).
351 foreach (['aioseo_options', 'aioseo_options_dynamic', 'aioseo_options_pro'] as $option_name) {
352 if (delete_option($option_name)) {
353 $deleted++;
354 }
355 }
356 }
357
358 // Clear detection cache
359 $detector = new Import_Detector();
360 $detector->clear_cache();
361
362 return new \WP_REST_Response([
363 'status' => 'complete',
364 'message' => sprintf('Deleted %d source data entries for %s', $deleted, $plugin),
365 'deleted' => $deleted,
366 ], 200);
367 }
368
369 /**
370 * DELETE /import/snapshot — Delete snapshot data from wp_options
371 *
372 * @param \WP_REST_Request $request Request object
373 * @return \WP_REST_Response
374 */
375 public function delete_snapshot(\WP_REST_Request $request): \WP_REST_Response {
376 $plugin = $request->get_param('plugin');
377 $deleted = Snapshot_Store::delete_snapshot($plugin);
378
379 return new \WP_REST_Response([
380 'status' => 'complete',
381 'message' => sprintf('Deleted %d snapshot options for %s', $deleted, $plugin),
382 'deleted' => $deleted,
383 ], 200);
384 }
385
386 /**
387 * Get the appropriate exporter instance for a plugin
388 *
389 * @param string $plugin Plugin slug
390 * @return Abstract_Plugin_Exporter|\WP_Error
391 */
392 private function get_exporter(string $plugin) {
393 return match ($plugin) {
394 'yoast' => new Yoast_Exporter(),
395 'rankmath' => new Rankmath_Exporter(),
396 'seopress' => new SEOPress_Exporter(),
397 'aioseo' => new AIOSEO_Exporter(),
398 default => new \WP_Error('invalid_plugin', 'Unsupported plugin: ' . $plugin, ['status' => 400]),
399 };
400 }
401
402 /**
403 * Get argument schema for export endpoint
404 *
405 * @return array
406 */
407 private function get_export_args(): array {
408 return [
409 'plugin' => [
410 'required' => true,
411 'type' => 'string',
412 'enum' => self::ALLOWED_PLUGINS,
413 'sanitize_callback' => 'sanitize_text_field',
414 ],
415 'type' => [
416 'required' => true,
417 'type' => 'string',
418 'enum' => self::ALLOWED_TYPES,
419 'sanitize_callback' => 'sanitize_text_field',
420 ],
421 'page' => [
422 'required' => true,
423 'type' => 'integer',
424 'minimum' => 1,
425 'sanitize_callback' => 'absint',
426 ],
427 'is_last_type' => [
428 'required' => false,
429 'type' => 'boolean',
430 'default' => false,
431 ],
432 ];
433 }
434
435 /**
436 * Get argument schema for migrate endpoint
437 *
438 * @return array
439 */
440 private function get_migrate_args(): array {
441 return [
442 'plugin' => [
443 'required' => true,
444 'type' => 'string',
445 'enum' => self::ALLOWED_PLUGINS,
446 'sanitize_callback' => 'sanitize_text_field',
447 ],
448 'type' => [
449 'required' => true,
450 'type' => 'string',
451 'enum' => self::ALLOWED_TYPES,
452 'sanitize_callback' => 'sanitize_text_field',
453 ],
454 'page' => [
455 'required' => true,
456 'type' => 'integer',
457 'minimum' => 1,
458 'sanitize_callback' => 'absint',
459 ],
460 'is_last_type' => [
461 'required' => false,
462 'type' => 'boolean',
463 'default' => false,
464 ],
465 ];
466 }
467 }
468