← All changes
|
includes/abilities/import/class-run-seo-import.php
+106
-12
2.5.0
→
2.13.0
View file →
| @@ -13,8 +13,10 @@ | ||
| 13 | 13 | use ThinkRank\Admin\Importers\AIOSEO_Exporter; |
| 14 | 14 | use ThinkRank\Admin\Importers\Rankmath_Exporter; |
| 15 | 15 | use ThinkRank\Admin\Importers\SEOPress_Exporter; |
| 16 | 16 | use ThinkRank\Admin\Importers\Snapshot_Migrator; |
| 17 | +use ThinkRank\Admin\Importers\Slim_SEO_Exporter; | |
| 18 | +use ThinkRank\Admin\Importers\Squirrly_Exporter; | |
| 17 | 19 | use ThinkRank\Admin\Importers\Yoast_Exporter; |
| 18 | 20 | |
| 19 | 21 | if ( ! defined( 'ABSPATH' ) ) { |
| 20 | 22 | exit; // Exit if accessed directly. |
| @@ -27,8 +29,12 @@ | ||
| 27 | 29 | * endpoints: it exports the chosen source (Yoast/RankMath/SEOPress/AIOSEO) into |
| 28 | 30 | * a snapshot, then migrates that snapshot into ThinkRank's `_thinkrank_*` |
| 29 | 31 | * metadata. Existing ThinkRank values are never overwritten. Source-plugin data |
| 30 | 32 | * is left intact (no cleanup); reports aggregate counters, not per-item rows. |
| 33 | + * | |
| 34 | + * One step is not metadata: `content_blocks` rewrites Rank Math FAQ / HowTo | |
| 35 | + * blocks inside `post_content` into ThinkRank blocks (#777). The description | |
| 36 | + * says so, and `types` lets a caller leave it out. | |
| 31 | 37 | */ |
| 32 | 38 | class Run_Seo_Import extends Ability_Base { |
| 33 | 39 | /** |
| 34 | 40 | * Source plugins that can be imported. |
| @@ -34,9 +40,9 @@ | ||
| 34 | 40 | * Source plugins that can be imported. |
| 35 | 41 | * |
| 36 | 42 | * @var string[] |
| 37 | 43 | */ |
| 38 | - private const ALLOWED_PLUGINS = [ 'yoast', 'rankmath', 'seopress', 'aioseo' ]; | |
| 44 | + private const ALLOWED_PLUGINS = [ 'yoast', 'rankmath', 'seopress', 'aioseo', 'squirrly', 'slimseo' ]; | |
| 39 | 45 | |
| 40 | 46 | /** |
| 41 | 47 | * Data types processed, in pipeline order. |
| 42 | 48 | * |
| @@ -41,9 +47,9 @@ | ||
| 41 | 47 | * Data types processed, in pipeline order. |
| 42 | 48 | * |
| 43 | 49 | * @var string[] |
| 44 | 50 | */ |
| 45 | - private const TYPES = [ 'postmeta', 'termmeta', 'usermeta', 'redirections', 'settings' ]; | |
| 51 | + private const TYPES = [ 'postmeta', 'termmeta', 'usermeta', 'redirections', 'content_blocks', 'settings' ]; | |
| 46 | 52 | |
| 47 | 53 | /** |
| 48 | 54 | * Safety cap on chunk iterations per type (avoids runaway loops). |
| 49 | 55 | */ |
| @@ -54,9 +60,9 @@ | ||
| 54 | 60 | */ |
| 55 | 61 | public function __construct() { |
| 56 | 62 | $this->id = 'thinkrank/run-seo-import'; |
| 57 | 63 | $this->label = __( 'Run SEO Data Import', 'thinkrank' ); |
| 58 | - $this->description = __( 'Import SEO metadata from another plugin (Yoast, RankMath, SEOPress, or AIOSEO) into ThinkRank. Exports the source to a snapshot then migrates it; existing ThinkRank values are never overwritten and source data is left intact. Returns aggregate export/migration counters. Run preview-seo-import first to see what would change; get-import-status reports on the snapshot afterwards.', 'thinkrank' ); | |
| 64 | + $this->description = __( 'Import SEO data from another plugin (Yoast, RankMath, SEOPress, AIOSEO, Squirrly, or Slim SEO) into ThinkRank. Exports the source to a snapshot then migrates it; existing ThinkRank values are never overwritten and the source plugin\'s own meta and settings are left intact. The content_blocks type (RankMath only) edits post content: it rewrites RankMath FAQ and HowTo blocks into ThinkRank blocks. Each converted post gets a revision, or a restorable backup where revisions are disabled. Pass types to limit the run, for example to leave content_blocks out. Posts with malformed block markup are left unchanged and listed in errors. Returns aggregate export/migration counters. Run preview-seo-import first to see what would change; get-import-status reports on the snapshot afterwards.', 'thinkrank' ); | |
| 59 | 65 | } |
| 60 | 66 | |
| 61 | 67 | /** |
| 62 | 68 | * {@inheritDoc} |
| @@ -65,8 +71,16 @@ | ||
| 65 | 71 | */ |
| 66 | 72 | public function get_annotations() { |
| 67 | 73 | return [ |
| 68 | 74 | 'readonly' => false, |
| 75 | + // Still false, deliberately, although content_blocks edits | |
| 76 | + // post_content. `destructive` is for losses that cannot be undone | |
| 77 | + // (#675), and this one can: every converted post gets a revision, | |
| 78 | + // and where revisions are off Block_Converter keeps the original | |
| 79 | + // in post meta for POST /import/content-blocks/restore. A post | |
| 80 | + // whose markup cannot be converted safely is not written at all. | |
| 81 | + // The description spells the content edit out instead, so the | |
| 82 | + // caller can decide, and `types` can leave the step out. | |
| 69 | 83 | 'destructive' => false, |
| 70 | 84 | 'idempotent' => false, |
| 71 | 85 | 'priority' => 2.0, |
| 72 | 86 | 'openWorldHint' => false, |
| @@ -87,8 +101,18 @@ | ||
| 87 | 101 | 'type' => 'string', |
| 88 | 102 | 'description' => __( 'The source SEO plugin to import from.', 'thinkrank' ), |
| 89 | 103 | 'enum' => self::ALLOWED_PLUGINS, |
| 90 | 104 | ], |
| 105 | + 'types' => [ | |
| 106 | + 'type' => 'array', | |
| 107 | + 'description' => __( 'Data types to import. Defaults to all of them. Omit content_blocks to leave post content untouched.', 'thinkrank' ), | |
| 108 | + 'items' => [ | |
| 109 | + 'type' => 'string', | |
| 110 | + 'enum' => self::TYPES, | |
| 111 | + ], | |
| 112 | + 'minItems' => 1, | |
| 113 | + 'uniqueItems' => true, | |
| 114 | + ], | |
| 91 | 115 | ], |
| 92 | 116 | 'required' => [ 'plugin' ], |
| 93 | 117 | ]; |
| 94 | 118 | } |
| @@ -107,8 +131,12 @@ | ||
| 107 | 131 | 'exported' => [ |
| 108 | 132 | 'type' => 'object', |
| 109 | 133 | 'additionalProperties' => true, |
| 110 | 134 | ], |
| 135 | + 'types' => [ | |
| 136 | + 'type' => 'array', | |
| 137 | + 'items' => [ 'type' => 'string' ], | |
| 138 | + ], | |
| 111 | 139 | 'migrated' => [ |
| 112 | 140 | 'type' => 'object', |
| 113 | 141 | 'additionalProperties' => true, |
| 114 | 142 | ], |
| @@ -131,13 +159,19 @@ | ||
| 131 | 159 | |
| 132 | 160 | if ( ! in_array( $plugin, self::ALLOWED_PLUGINS, true ) ) { |
| 133 | 161 | return new \WP_Error( |
| 134 | 162 | 'thinkrank_invalid_import_plugin', |
| 135 | - __( 'A supported source plugin is required (yoast, rankmath, seopress, aioseo).', 'thinkrank' ), | |
| 163 | + __( 'A supported source plugin is required (yoast, rankmath, seopress, aioseo, squirrly, slimseo).', 'thinkrank' ), | |
| 136 | 164 | [ 'status' => 400 ] |
| 137 | 165 | ); |
| 138 | 166 | } |
| 139 | 167 | |
| 168 | + $types = $this->resolve_types( $input['types'] ?? null ); | |
| 169 | + | |
| 170 | + if ( is_wp_error( $types ) ) { | |
| 171 | + return $types; | |
| 172 | + } | |
| 173 | + | |
| 140 | 174 | $exporter = $this->get_exporter( $plugin ); |
| 141 | 175 | |
| 142 | 176 | if ( null === $exporter ) { |
| 143 | 177 | return new \WP_Error( |
| @@ -147,10 +181,10 @@ | ||
| 147 | 181 | ); |
| 148 | 182 | } |
| 149 | 183 | |
| 150 | 184 | try { |
| 151 | - $exported = $this->run_export( $exporter ); | |
| 152 | - $migrated = $this->run_migration( $plugin ); | |
| 185 | + $exported = $this->run_export( $exporter, $types ); | |
| 186 | + $migrated = $this->run_migration( $plugin, $types ); | |
| 153 | 187 | } catch ( \Throwable $e ) { |
| 154 | 188 | return new \WP_Error( |
| 155 | 189 | 'thinkrank_import_failed', |
| 156 | 190 | $e->getMessage(), |
| @@ -160,12 +194,14 @@ | ||
| 160 | 194 | |
| 161 | 195 | return [ |
| 162 | 196 | 'success' => empty( $migrated['errors'] ), |
| 163 | 197 | 'plugin' => $plugin, |
| 198 | + 'types' => $types, | |
| 164 | 199 | 'exported' => $exported, |
| 165 | 200 | 'migrated' => [ |
| 166 | 201 | 'processed' => $migrated['processed'], |
| 167 | 202 | 'skipped' => $migrated['skipped'], |
| 203 | + 'failed' => $migrated['failed'], | |
| 168 | 204 | 'analyzed' => $migrated['analyzed'], |
| 169 | 205 | 'keywords_seeded' => $migrated['keywords_seeded'], |
| 170 | 206 | ], |
| 171 | 207 | 'errors' => $migrated['errors'], |
| @@ -172,8 +208,50 @@ | ||
| 172 | 208 | ]; |
| 173 | 209 | } |
| 174 | 210 | |
| 175 | 211 | /** |
| 212 | + * The types to run, in pipeline order. | |
| 213 | + * | |
| 214 | + * Absent means every type, which is what the ability did before `types` | |
| 215 | + * existed. Order always follows TYPES rather than the caller's list, since | |
| 216 | + * the migrator relies on postmeta running before settings. | |
| 217 | + * | |
| 218 | + * @param mixed $requested The `types` input, if any. | |
| 219 | + * @return string[]|\WP_Error | |
| 220 | + */ | |
| 221 | + private function resolve_types( $requested ) { | |
| 222 | + if ( null === $requested ) { | |
| 223 | + return self::TYPES; | |
| 224 | + } | |
| 225 | + | |
| 226 | + if ( ! is_array( $requested ) || empty( $requested ) ) { | |
| 227 | + return new \WP_Error( | |
| 228 | + 'thinkrank_invalid_import_types', | |
| 229 | + __( 'types must be a non-empty list of data types.', 'thinkrank' ), | |
| 230 | + [ 'status' => 400 ] | |
| 231 | + ); | |
| 232 | + } | |
| 233 | + | |
| 234 | + $requested = array_map( 'sanitize_key', array_map( 'strval', $requested ) ); | |
| 235 | + $unknown = array_diff( $requested, self::TYPES ); | |
| 236 | + | |
| 237 | + if ( ! empty( $unknown ) ) { | |
| 238 | + return new \WP_Error( | |
| 239 | + 'thinkrank_invalid_import_types', | |
| 240 | + sprintf( | |
| 241 | + /* translators: 1: unknown type slugs, 2: allowed type slugs. */ | |
| 242 | + __( 'Unknown import types: %1$s. Allowed: %2$s.', 'thinkrank' ), | |
| 243 | + implode( ', ', $unknown ), | |
| 244 | + implode( ', ', self::TYPES ) | |
| 245 | + ), | |
| 246 | + [ 'status' => 400 ] | |
| 247 | + ); | |
| 248 | + } | |
| 249 | + | |
| 250 | + return array_values( array_intersect( self::TYPES, $requested ) ); | |
| 251 | + } | |
| 252 | + | |
| 253 | + /** | |
| 176 | 254 | * Resolve the exporter for a source plugin. |
| 177 | 255 | * |
| 178 | 256 | * @param string $plugin Source plugin slug. |
| 179 | 257 | * @return object|null Exporter instance or null when unknown. |
| @@ -187,8 +265,12 @@ | ||
| 187 | 265 | case 'seopress': |
| 188 | 266 | return new SEOPress_Exporter(); |
| 189 | 267 | case 'aioseo': |
| 190 | 268 | return new AIOSEO_Exporter(); |
| 269 | + case 'squirrly': | |
| 270 | + return new Squirrly_Exporter(); | |
| 271 | + case 'slimseo': | |
| 272 | + return new Slim_SEO_Exporter(); | |
| 191 | 273 | default: |
| 192 | 274 | return null; |
| 193 | 275 | } |
| 194 | 276 | } |
| @@ -195,15 +277,16 @@ | ||
| 195 | 277 | |
| 196 | 278 | /** |
| 197 | 279 | * Export every data type into a snapshot and finalize it. |
| 198 | 280 | * |
| 199 | - * @param object $exporter Source-plugin exporter. | |
| 281 | + * @param object $exporter Source-plugin exporter. | |
| 282 | + * @param string[] $types Types to export, in pipeline order. | |
| 200 | 283 | * @return array<string, int> Per-type exported counts. |
| 201 | 284 | */ |
| 202 | - private function run_export( $exporter ) { | |
| 285 | + private function run_export( $exporter, array $types ) { | |
| 203 | 286 | $exported = []; |
| 204 | 287 | |
| 205 | - foreach ( self::TYPES as $type ) { | |
| 288 | + foreach ( $types as $type ) { | |
| 206 | 289 | $count = 0; |
| 207 | 290 | $page = 1; |
| 208 | 291 | |
| 209 | 292 | do { |
| @@ -224,22 +307,24 @@ | ||
| 224 | 307 | |
| 225 | 308 | /** |
| 226 | 309 | * Migrate the snapshot into ThinkRank metadata. |
| 227 | 310 | * |
| 228 | - * @param string $plugin Source plugin slug. | |
| 311 | + * @param string $plugin Source plugin slug. | |
| 312 | + * @param string[] $types Types to migrate, in pipeline order. | |
| 229 | 313 | * @return array<string, mixed> Aggregate counters and any errors. |
| 230 | 314 | */ |
| 231 | - private function run_migration( $plugin ) { | |
| 315 | + private function run_migration( $plugin, array $types ) { | |
| 232 | 316 | $migrator = new Snapshot_Migrator(); |
| 233 | 317 | $totals = [ |
| 234 | 318 | 'processed' => 0, |
| 235 | 319 | 'skipped' => 0, |
| 320 | + 'failed' => 0, | |
| 236 | 321 | 'analyzed' => 0, |
| 237 | 322 | 'keywords_seeded' => 0, |
| 238 | 323 | 'errors' => [], |
| 239 | 324 | ]; |
| 240 | 325 | |
| 241 | - foreach ( self::TYPES as $type ) { | |
| 326 | + foreach ( $types as $type ) { | |
| 242 | 327 | $page = 1; |
| 243 | 328 | |
| 244 | 329 | do { |
| 245 | 330 | $result = $migrator->migrate_chunk( $plugin, $type, $page ); |
| @@ -256,8 +341,17 @@ | ||
| 256 | 341 | $totals['processed'] += (int) ( $result['processed'] ?? 0 ); |
| 257 | 342 | $totals['skipped'] += (int) ( $result['skipped'] ?? 0 ); |
| 258 | 343 | $totals['analyzed'] += (int) ( $result['analyzed'] ?? 0 ); |
| 259 | 344 | $totals['keywords_seeded'] += (int) ( $result['keywords_seeded'] ?? 0 ); |
| 345 | + $totals['failed'] += (int) ( $result['failed'] ?? 0 ); | |
| 346 | + | |
| 347 | + // Per-post failures (content_blocks: a post whose block markup | |
| 348 | + // could not be converted safely and was left as it was). The | |
| 349 | + // chunk itself succeeded, so they are not a chunk error, but | |
| 350 | + // the caller needs the post ids to go and fix them. | |
| 351 | + foreach ( (array) ( $result['failures'] ?? [] ) as $failure ) { | |
| 352 | + $totals['errors'][] = (string) ( $failure['message'] ?? '' ); | |
| 353 | + } | |
| 260 | 354 | |
| 261 | 355 | $has_more = ! empty( $result['has_more'] ); |
| 262 | 356 | ++$page; |
| 263 | 357 | } while ( $has_more && $page <= self::MAX_PAGES ); |