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