PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
2.13.0 2.12.0 2.11.0 2.10.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 All 54 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.13.0, at includes/admin/importers/class-import-controller.php

730 lines 29.1 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', 'slimseo'];
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', 'slimseo', '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 = [
65 'postmeta',
66 'termmeta',
67 'usermeta',
68 'redirections',
69 '404_logs',
70 'settings',
71 Block_Converter::TYPE,
72 ];
73
74 /**
75 * Types the export and migrate endpoints accept.
76 *
77 * The fixed list above plus anything Pro registered through
78 * `thinkrank_export_types` — without this a Pro type would be exportable in
79 * principle and rejected at the route.
80 *
81 * @return string[]
82 */
83 private function get_allowed_types(): array {
84 return array_values(array_unique(array_merge(
85 self::ALLOWED_TYPES,
86 Thinkrank_Exporter::get_exportable_types()
87 )));
88 }
89
90 /**
91 * Register REST routes
92 *
93 * @return void
94 */
95 public function register_routes(): void {
96 register_rest_route($this->namespace, '/' . $this->rest_base . '/detect', [
97 [
98 'methods' => \WP_REST_Server::READABLE,
99 'callback' => [$this, 'detect'],
100 'permission_callback' => [$this, 'check_permissions'],
101 'args' => [
102 'refresh' => [
103 'type' => 'boolean',
104 'default' => false,
105 'sanitize_callback' => 'rest_sanitize_boolean',
106 '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'),
107 ],
108 ],
109 ],
110 ]);
111
112 register_rest_route($this->namespace, '/' . $this->rest_base . '/export', [
113 [
114 'methods' => \WP_REST_Server::CREATABLE,
115 'callback' => [$this, 'export'],
116 'permission_callback' => [$this, 'check_permissions'],
117 'args' => $this->get_export_args(),
118 ],
119 ]);
120
121 register_rest_route($this->namespace, '/' . $this->rest_base . '/snapshots', [
122 [
123 'methods' => \WP_REST_Server::READABLE,
124 'callback' => [$this, 'get_snapshots'],
125 'permission_callback' => [$this, 'check_permissions'],
126 ],
127 ]);
128
129 register_rest_route($this->namespace, '/' . $this->rest_base . '/migrate', [
130 [
131 'methods' => \WP_REST_Server::CREATABLE,
132 'callback' => [$this, 'migrate'],
133 'permission_callback' => [$this, 'check_permissions'],
134 'args' => $this->get_migrate_args(),
135 ],
136 ]);
137
138 register_rest_route($this->namespace, '/' . $this->rest_base . '/cleanup', [
139 [
140 'methods' => \WP_REST_Server::CREATABLE,
141 'callback' => [$this, 'cleanup'],
142 'permission_callback' => [$this, 'check_permissions'],
143 'args' => [
144 'plugin' => [
145 'required' => true,
146 'type' => 'string',
147 // Source plugins only — see SOURCE_PLUGINS.
148 'enum' => self::SOURCE_PLUGINS,
149 'sanitize_callback' => 'sanitize_text_field',
150 ],
151 // Required to proceed while the snapshot still holds
152 // extended data with no migration path (see cleanup()).
153 'force' => [
154 'required' => false,
155 'type' => 'boolean',
156 'default' => false,
157 ],
158 ],
159 ],
160 ]);
161
162 // Undo for the Rank Math FAQ/HowTo block conversion on sites with
163 // revisions disabled, where Block_Converter kept the original content
164 // in post meta. With revisions on, the post's revision history is the
165 // undo and this route has nothing to restore.
166 register_rest_route($this->namespace, '/' . $this->rest_base . '/content-blocks/restore', [
167 [
168 'methods' => \WP_REST_Server::CREATABLE,
169 'callback' => [$this, 'restore_content_blocks'],
170 'permission_callback' => [$this, 'check_permissions'],
171 'args' => [
172 'post_id' => [
173 'type' => 'integer',
174 'default' => 0,
175 'minimum' => 0,
176 'sanitize_callback' => 'absint',
177 'description' => __('Restore one post. Omit to restore every post with a backup, one page at a time.', 'thinkrank'),
178 ],
179 'after' => [
180 'type' => 'integer',
181 'default' => 0,
182 'minimum' => 0,
183 'sanitize_callback' => 'absint',
184 'description' => __('Paging cursor: pass the previous response\'s next_after.', 'thinkrank'),
185 ],
186 'force' => [
187 'type' => 'boolean',
188 'default' => false,
189 'description' => __('Restore posts that were edited after the conversion, discarding those edits.', 'thinkrank'),
190 ],
191 ],
192 ],
193 ]);
194
195 register_rest_route($this->namespace, '/' . $this->rest_base . '/snapshot', [
196 [
197 'methods' => \WP_REST_Server::DELETABLE,
198 'callback' => [$this, 'delete_snapshot'],
199 'permission_callback' => [$this, 'check_permissions'],
200 'args' => [
201 'plugin' => [
202 'required' => true,
203 'type' => 'string',
204 'enum' => self::ALLOWED_PLUGINS,
205 'sanitize_callback' => 'sanitize_text_field',
206 ],
207 ],
208 ],
209 ]);
210 }
211
212 /**
213 * Permission check — manage_options required
214 *
215 * @return bool
216 */
217 public function check_permissions(): bool {
218 return current_user_can('manage_options');
219 }
220
221 /**
222 * GET /import/detect — Detect source plugins, ThinkRank's own exportable
223 * data, and existing snapshots
224 *
225 * `refresh` drops the hour-long detection transient before scanning. Without
226 * it the Re-scan button could not do the one thing it exists for: pick up a
227 * source plugin the user just installed, activated or added data to.
228 *
229 * @param \WP_REST_Request $request Request object
230 * @return \WP_REST_Response
231 */
232 public function detect(\WP_REST_Request $request): \WP_REST_Response {
233 $detector = new Import_Detector();
234
235 if ($request->get_param('refresh')) {
236 $detector->clear_cache();
237 }
238
239 $detected = $detector->detect();
240 $snapshots = Snapshot_Store::get_available_snapshots();
241
242 return new \WP_REST_Response([
243 'detected' => $detected,
244 // ThinkRank's own exportable data, reported separately from the
245 // source plugins the user can migrate FROM.
246 'native' => $detector->detect_native(),
247 'snapshots' => $snapshots,
248 ], 200);
249 }
250
251 /**
252 * POST /import/export — Export a batch of source data to snapshot
253 *
254 * @param \WP_REST_Request $request Request object
255 * @return \WP_REST_Response|\WP_Error
256 */
257 public function export(\WP_REST_Request $request) {
258 $plugin = $request->get_param('plugin');
259 $type = $request->get_param('type');
260 $page = (int) $request->get_param('page');
261
262 $exporter = $this->get_exporter($plugin);
263 if (is_wp_error($exporter)) {
264 return $exporter;
265 }
266
267 // Start a run from an empty slot. update_manifest() merges into whatever
268 // manifest is already there, so without this the file the user ends up
269 // downloading carries the union of this run and every run before it.
270 if ((bool) $request->get_param('reset')) {
271 Snapshot_Store::delete_snapshot($plugin);
272 }
273
274 $result = $exporter->export_chunk($type, $page);
275
276 // If this type is complete and it's the last type, finalize
277 if (!$result['has_more'] && $request->get_param('is_last_type')) {
278 $exporter->finalize_export();
279 }
280
281 return new \WP_REST_Response($result, 200);
282 }
283
284 /**
285 * GET /import/snapshots — List existing snapshots
286 *
287 * @param \WP_REST_Request $request Request object
288 * @return \WP_REST_Response
289 */
290 public function get_snapshots(\WP_REST_Request $request): \WP_REST_Response {
291 $snapshots = Snapshot_Store::get_available_snapshots();
292 return new \WP_REST_Response(['snapshots' => $snapshots], 200);
293 }
294
295 /**
296 * POST /import/migrate — Migrate a batch from snapshot to ThinkRank meta,
297 * or restore one from ThinkRank's own export
298 *
299 * @param \WP_REST_Request $request Request object
300 * @return \WP_REST_Response
301 */
302 public function migrate(\WP_REST_Request $request): \WP_REST_Response {
303 $plugin = $request->get_param('plugin');
304 $type = $request->get_param('type');
305 $page = (int) $request->get_param('page');
306 $conflict = (string) $request->get_param('conflict');
307
308 $migrator = new Snapshot_Migrator();
309 $result = $migrator->migrate_chunk($plugin, $type, $page, $conflict);
310
311 // If migration is complete for all types, update manifest
312 if (!$result['has_more'] && $request->get_param('is_last_type')) {
313 $migrator->update_manifest_migration_info($plugin);
314 }
315
316 return new \WP_REST_Response($result, 200);
317 }
318
319 /**
320 * POST /import/cleanup — Remove source plugin meta from database
321 *
322 * Deletes SOURCE plugin data only — the ThinkRank snapshot is never touched
323 * by this endpoint (use DELETE /import/snapshot for that). Because the
324 * snapshot may still hold extended data ThinkRank cannot apply yet (e.g.
325 * redirections, owed to the Pro Redirections feature), cleanup is gated:
326 * while such buckets exist the request is rejected with HTTP 409 unless
327 * force=true is passed, so the user explicitly acknowledges that the
328 * snapshot becomes the only copy of that data.
329 *
330 * @param \WP_REST_Request $request Request object
331 * @return \WP_REST_Response|\WP_Error
332 */
333 public function cleanup(\WP_REST_Request $request) {
334 global $wpdb;
335
336 $plugin = $request->get_param('plugin');
337 $force = (bool) $request->get_param('force');
338 $deleted = 0;
339
340 // Belt and braces on top of the route's SOURCE_PLUGINS enum: this
341 // endpoint deletes the SOURCE plugin's live meta and options, so
342 // pointing it at ThinkRank would delete the user's own SEO data.
343 if ($plugin === self::NATIVE_PLUGIN) {
344 return new \WP_Error(
345 'thinkrank_cleanup_not_applicable',
346 __('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'),
347 ['status' => 400]
348 );
349 }
350
351 // Gate: block while the snapshot holds preserved-but-unapplied extended
352 // data, unless the caller explicitly forces the cleanup.
353 if (!$force) {
354 $migrator = new Snapshot_Migrator();
355 $unmigrated = $migrator->get_unmigrated_extended_buckets($plugin);
356
357 if (!empty($unmigrated)) {
358 $labels = array_map(
359 static function (array $bucket): string {
360 return $bucket['count'] > 1
361 ? sprintf('%s (%d)', $bucket['label'], $bucket['count'])
362 : (string) $bucket['label'];
363 },
364 $unmigrated
365 );
366
367 return new \WP_Error(
368 'thinkrank_cleanup_blocked',
369 sprintf(
370 /* translators: %s: comma-separated list of unapplied data buckets. */
371 __('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'),
372 implode(', ', $labels)
373 ),
374 [
375 'status' => 409,
376 'preserved' => $unmigrated,
377 'requires_force' => true,
378 ]
379 );
380 }
381 }
382
383 $prefix_map = [
384 'yoast' => '_yoast_wpseo_',
385 'rankmath' => 'rank_math_',
386 'seopress' => '_seopress_',
387 'aioseo' => null, // Custom table
388 'squirrly' => '_sq_', // Fallback meta only; the SEO is in the qss table below
389 // Slim SEO is cleaned by exact key below: a `slim_seo` prefix also
390 // matches Slim SEO Pro's and its add-ons' own keys (#886).
391 'slimseo' => null,
392 ];
393
394 $prefix = $prefix_map[$plugin] ?? null;
395
396 if ($prefix) {
397 // Delete from postmeta
398 $deleted += (int) $wpdb->query(
399 $wpdb->prepare(
400 "DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE %s",
401 $wpdb->esc_like($prefix) . '%'
402 )
403 );
404
405 // Delete from termmeta
406 $deleted += (int) $wpdb->query(
407 $wpdb->prepare(
408 "DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
409 $wpdb->esc_like($prefix) . '%'
410 )
411 );
412
413 // Delete author-level SEO meta from usermeta. Yoast keys user meta
414 // under a different prefix than its post meta, so map it explicitly;
415 // the others reuse their post-meta prefix.
416 $usermeta_prefix_map = [
417 'yoast' => 'wpseo_',
418 'rankmath' => 'rank_math_',
419 'seopress' => '_seopress_',
420 'squirrly' => '_sq_',
421 ];
422 $usermeta_prefix = $usermeta_prefix_map[$plugin] ?? $prefix;
423 $deleted += (int) $wpdb->query(
424 $wpdb->prepare(
425 "DELETE FROM {$wpdb->usermeta} WHERE meta_key LIKE %s",
426 $wpdb->esc_like($usermeta_prefix) . '%'
427 )
428 );
429
430 // Delete the source plugin's option rows — the exact options each
431 // exporter reads, so the site-level settings we migrated are removed
432 // too rather than left orphaned.
433 $option_keys_map = [
434 'yoast' => ['wpseo', 'wpseo_titles', 'wpseo_social', 'wpseo_taxonomy_meta'],
435 'rankmath' => ['rank-math-options-general', 'rank-math-options-titles', 'rank-math-options-sitemap', 'rank-math-options-instant-indexing'],
436 'seopress' => ['seopress_titles_option_name', 'seopress_social_option_name', 'seopress_advanced_option_name', 'seopress_xml_sitemap_option_name', 'seopress_instant_indexing_option_name'],
437 'squirrly' => ['sq_options'],
438 ];
439 foreach ($option_keys_map[$plugin] ?? [] as $option_name) {
440 if (delete_option($option_name)) {
441 $deleted++;
442 }
443 }
444 }
445
446 if ($plugin === 'slimseo') {
447 // Exactly the keys the exporter reads. A `slim_seo%` prefix would
448 // also take data nothing imported (Slim SEO Link Manager's and
449 // Auto Link's settings), and those stay with their plugin.
450 $deleted += (int) $wpdb->query(
451 $wpdb->prepare(
452 "DELETE FROM {$wpdb->postmeta} WHERE meta_key IN (%s, %s, %s, %s, %s) OR meta_key LIKE %s",
453 Slim_SEO_Exporter::META_KEY,
454 Slim_SEO_Exporter::PRO_META_KEY,
455 Slim_SEO_Exporter::SCHEMA_META_KEY,
456 Slim_SEO_Exporter::ALLOW_GLOBAL_META,
457 Slim_SEO_Exporter::OLD_PERMALINK_META,
458 $wpdb->esc_like(Slim_SEO_Exporter::PRIMARY_TERM_PREFIX) . '%'
459 )
460 );
461 $deleted += (int) $wpdb->query(
462 $wpdb->prepare("DELETE FROM {$wpdb->termmeta} WHERE meta_key = %s", Slim_SEO_Exporter::META_KEY)
463 );
464 $options = [
465 Slim_SEO_Exporter::OPTION,
466 Slim_SEO_Exporter::REDIRECTS_OPTION,
467 Slim_SEO_Exporter::PRO_OPTION,
468 Slim_SEO_Exporter::SCHEMAS_OPTION,
469 'slim_seo_db_version',
470 'ss_redirection_db_version',
471 ];
472 foreach ($options as $option_name) {
473 if (delete_option($option_name)) {
474 $deleted++;
475 }
476 }
477 $table = $wpdb->prefix . Slim_SEO_Exporter::LOG_404_TABLE;
478 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table))) {
479 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal.
480 $deleted += (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
481 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal.
482 $wpdb->query("DROP TABLE {$table}");
483 }
484 }
485
486 if ($plugin === 'squirrly') {
487 // Drop Squirrly's custom tables: per-URL SEO, Advanced Pack
488 // redirects and their logs, reusable JSON-LD templates.
489 foreach (['qss', 'qss_redirects', 'qss_redirects_logs', 'qss_jsonld'] as $suffix) {
490 $table = $wpdb->prefix . $suffix;
491 $table_exists = $wpdb->get_var(
492 $wpdb->prepare("SHOW TABLES LIKE %s", $table)
493 );
494 if ($table_exists) {
495 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
496 $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
497 // 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.
498 $wpdb->query("DROP TABLE {$table}");
499 $deleted += $count;
500 }
501 }
502 }
503
504 if ($plugin === 'aioseo') {
505 // Drop the AIOSEO custom tables the exporter reads (posts + Pro terms).
506 foreach (['aioseo_posts', 'aioseo_terms'] as $suffix) {
507 $table = $wpdb->prefix . $suffix;
508 $table_exists = $wpdb->get_var(
509 $wpdb->prepare("SHOW TABLES LIKE %s", $table)
510 );
511 if ($table_exists) {
512 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
513 $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
514 // 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.
515 $wpdb->query("DROP TABLE {$table}");
516 $deleted += $count;
517 }
518 }
519
520 // Legacy term meta (aioseo_*-prefixed) some versions used.
521 $deleted += (int) $wpdb->query(
522 $wpdb->prepare(
523 "DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
524 $wpdb->esc_like('aioseo_') . '%'
525 )
526 );
527
528 // The option blobs the exporter reads (settings, dynamic per-type
529 // templates, Pro add-on settings).
530 foreach (['aioseo_options', 'aioseo_options_dynamic', 'aioseo_options_pro'] as $option_name) {
531 if (delete_option($option_name)) {
532 $deleted++;
533 }
534 }
535 }
536
537 // Clear detection cache
538 $detector = new Import_Detector();
539 $detector->clear_cache();
540
541 return new \WP_REST_Response([
542 'status' => 'complete',
543 'message' => sprintf('Deleted %d source data entries for %s', $deleted, $plugin),
544 'deleted' => $deleted,
545 ], 200);
546 }
547
548 /**
549 * POST /import/content-blocks/restore: put converted posts back.
550 *
551 * Restores the pre-conversion `post_content` Block_Converter kept for posts
552 * converted while revisions were disabled. A post edited since is reported
553 * under `modified` and left alone unless `force` is set.
554 *
555 * Without `post_id` it works through one page of posts per call; while
556 * `next_after` is non-null, call again with `after` set to it.
557 *
558 * @since 2.10.0
559 *
560 * @param \WP_REST_Request $request Request object
561 * @return \WP_REST_Response
562 */
563 public function restore_content_blocks(\WP_REST_Request $request): \WP_REST_Response {
564 $post_id = (int) $request->get_param('post_id');
565 $force = (bool) $request->get_param('force');
566 $limit = Block_Converter::chunk_size();
567
568 $ids = $post_id > 0
569 ? [$post_id]
570 : Block_Converter::get_backup_post_ids((int) $request->get_param('after'), $limit);
571
572 $report = [
573 'restored' => [],
574 'modified' => [],
575 'no_backup' => [],
576 'errors' => [],
577 'next_after' => null,
578 ];
579
580 foreach ($ids as $id) {
581 $result = Block_Converter::restore_post($id, $force);
582
583 if ('error' === $result['status']) {
584 $report['errors'][] = ['post_id' => $id, 'message' => $result['message']];
585 continue;
586 }
587
588 $report[$result['status']][] = $id;
589 }
590
591 if ($post_id < 1 && count($ids) === $limit) {
592 $report['next_after'] = (int) end($ids);
593 }
594
595 return new \WP_REST_Response($report, 200);
596 }
597
598 /**
599 * DELETE /import/snapshot — Delete snapshot data from wp_options
600 *
601 * @param \WP_REST_Request $request Request object
602 * @return \WP_REST_Response
603 */
604 public function delete_snapshot(\WP_REST_Request $request): \WP_REST_Response {
605 $plugin = $request->get_param('plugin');
606 $deleted = Snapshot_Store::delete_snapshot($plugin);
607
608 return new \WP_REST_Response([
609 'status' => 'complete',
610 'message' => sprintf('Deleted %d snapshot options for %s', $deleted, $plugin),
611 'deleted' => $deleted,
612 ], 200);
613 }
614
615 /**
616 * Get the appropriate exporter instance for a plugin
617 *
618 * @param string $plugin Plugin slug
619 * @return Abstract_Plugin_Exporter|\WP_Error
620 */
621 private function get_exporter(string $plugin) {
622 switch ($plugin) {
623 case 'yoast':
624 return new Yoast_Exporter();
625 case 'rankmath':
626 return new Rankmath_Exporter();
627 case 'seopress':
628 return new SEOPress_Exporter();
629 case 'aioseo':
630 return new AIOSEO_Exporter();
631 case Squirrly_Exporter::SLUG:
632 return new Squirrly_Exporter();
633 case Slim_SEO_Exporter::SLUG:
634 return new Slim_SEO_Exporter();
635 case Thinkrank_Exporter::SLUG:
636 return new Thinkrank_Exporter();
637 default:
638 return new \WP_Error('invalid_plugin', 'Unsupported plugin: ' . $plugin, ['status' => 400]);
639 }
640 }
641
642 /**
643 * Get argument schema for export endpoint
644 *
645 * @return array
646 */
647 private function get_export_args(): array {
648 return [
649 'plugin' => [
650 'required' => true,
651 'type' => 'string',
652 'enum' => self::ALLOWED_PLUGINS,
653 'sanitize_callback' => 'sanitize_text_field',
654 ],
655 'type' => [
656 'required' => true,
657 'type' => 'string',
658 'enum' => $this->get_allowed_types(),
659 'sanitize_callback' => 'sanitize_text_field',
660 ],
661 'page' => [
662 'required' => true,
663 'type' => 'integer',
664 'minimum' => 1,
665 'sanitize_callback' => 'absint',
666 ],
667 'is_last_type' => [
668 'required' => false,
669 'type' => 'boolean',
670 'default' => false,
671 ],
672 // Set on the first chunk of a run to discard whatever is already in
673 // the snapshot slot. Without it a run inherits the previous one's
674 // types: the manifest is merged into, never replaced, so a type the
675 // user deselected (or a file they uploaded and chose not to
676 // restore) stays in the snapshot and is streamed by
677 // /export/download, which sends every type the manifest lists.
678 'reset' => [
679 'required' => false,
680 'type' => 'boolean',
681 'default' => false,
682 ],
683 ];
684 }
685
686 /**
687 * Get argument schema for migrate endpoint
688 *
689 * @return array
690 */
691 private function get_migrate_args(): array {
692 return [
693 'plugin' => [
694 'required' => true,
695 'type' => 'string',
696 'enum' => self::ALLOWED_PLUGINS,
697 'sanitize_callback' => 'sanitize_text_field',
698 ],
699 // Defaults to skip, which is the safe answer for an import from
700 // another plugin: its data must never clobber something already
701 // set here. A restore from a ThinkRank backup passes overwrite —
702 // getting the saved values back is the entire point of it.
703 'conflict' => [
704 'required' => false,
705 'type' => 'string',
706 'enum' => [Snapshot_Migrator::CONFLICT_SKIP, Snapshot_Migrator::CONFLICT_OVERWRITE],
707 'default' => Snapshot_Migrator::CONFLICT_SKIP,
708 'sanitize_callback' => 'sanitize_text_field',
709 ],
710 'type' => [
711 'required' => true,
712 'type' => 'string',
713 'enum' => $this->get_allowed_types(),
714 'sanitize_callback' => 'sanitize_text_field',
715 ],
716 'page' => [
717 'required' => true,
718 'type' => 'integer',
719 'minimum' => 1,
720 'sanitize_callback' => 'absint',
721 ],
722 'is_last_type' => [
723 'required' => false,
724 'type' => 'boolean',
725 'default' => false,
726 ],
727 ];
728 }
729 }
730