PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
thinkrank / includes / admin / importers / class-thinkrank-exporter.php

class-thinkrank-exporter.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.9.0, at includes/admin/importers/class-thinkrank-exporter.php

645 lines 22.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * ThinkRank Exporter
5 *
6 * Exports ThinkRank's OWN data so a user can take it with them, move it to
7 * another site, or keep it as a backup.
8 *
9 * Unlike the source-plugin exporters, this one does NOT normalize into the
10 * canonical format the migrator maps through Snapshot_Migrator::META_MAP.
11 * That round-trip is lossy for our own data: migrate_robots_payload() rebuilds
12 * `_thinkrank_robots_meta` from canonical flags (deriving `index`,
13 * `snippet_enabled`, …) and writes nothing when every flag is false, and every
14 * meta key outside META_MAP — `_thinkrank_seo_score`, `_thinkrank_metadata`,
15 * `_thinkrank_seo_settings`, `_thinkrank_focus_keywords_overflow`, and anything
16 * added in future — is dropped. For a backup, fidelity is the point, so records
17 * carry raw `_thinkrank_*` key => value pairs verbatim and the restore side
18 * writes them back unchanged.
19 *
20 * @package ThinkRank\Admin\Importers
21 * @since 2.2.0
22 */
23
24 declare(strict_types=1);
25
26 namespace ThinkRank\Admin\Importers;
27
28 use ThinkRank\Core\Settings;
29
30 if (!defined('ABSPATH')) {
31 exit;
32 }
33
34 /**
35 * ThinkRank Exporter Class
36 *
37 * @since 2.2.0
38 */
39 class Thinkrank_Exporter extends Abstract_Plugin_Exporter {
40
41 /**
42 * Snapshot slug for ThinkRank's own data.
43 */
44 public const SLUG = 'thinkrank';
45
46 /**
47 * Meta key prefix for all ThinkRank post/term/user meta.
48 */
49 public const META_PREFIX = '_thinkrank_';
50
51 /**
52 * Aggregate settings options written directly by feature managers and the
53 * migrator, rather than through Settings (which uses one option per key).
54 *
55 * None of these collide with a `thinkrank_{key}` option from
56 * Settings::$defaults, so the two buckets stay disjoint.
57 *
58 * Public because the restore side uses it as an allow-list: a hand-edited
59 * export must not be able to write arbitrary site options.
60 */
61 public const AGGREGATE_OPTIONS = [
62 'thinkrank_global_seo_settings',
63 'thinkrank_site_identity_settings',
64 'thinkrank_social_media_settings',
65 'thinkrank_global_robot_meta_settings',
66 'thinkrank_instant_indexing_settings',
67 'thinkrank_image_seo_settings',
68 'thinkrank_email_report_settings',
69 'thinkrank_author_archives_settings',
70 ];
71
72 /**
73 * Data types the free plugin exports itself.
74 */
75 private const CORE_TYPES = ['postmeta', 'termmeta', 'usermeta', 'settings'];
76
77 /**
78 * Secrets that are NOT in Settings::$encrypted_keys but must never reach an
79 * export file either: the per-platform API keys of the removed Brand
80 * Visibility feature. They were stored as plain options, and a site that
81 * used the feature still has them, so the exclusion outlives the feature.
82 *
83 * The encrypted keys themselves come from Settings::get_encrypted_keys() so
84 * this list cannot drift from that one.
85 */
86 private const EXTRA_SECRET_KEYS = [
87 'bv_key_chatgpt',
88 'bv_key_gemini',
89 'bv_key_claude',
90 'bv_key_perplexity',
91 ];
92
93 /**
94 * Credential-shaped keys nested INSIDE an aggregate option.
95 *
96 * The flat secret list matches on key name wherever it appears, which is
97 * the right rule for names that are unambiguous on their own
98 * (`openai_api_key`). A bare `api_key` is not: it is only a secret because
99 * of the option it sits in, so it is scoped here rather than banned
100 * everywhere.
101 *
102 * IndexNow's key is public by design — the plugin serves it at
103 * `/<key>.txt` — so this is hygiene rather than a leak being closed. It is
104 * still stripped, because it is the only credential-shaped value that
105 * currently reaches an export and leaving one exception in place is what
106 * let redaction drift out of two of the three buckets to begin with.
107 *
108 * Snapshot_Migrator reads this to put the LOCAL value back on restore, so
109 * stripping a key here never wipes the one the receiving site already has.
110 *
111 * @var array<string, string[]> Option name => secret keys inside it.
112 */
113 public const SECRET_OPTION_KEYS = [
114 'thinkrank_instant_indexing_settings' => ['api_key'],
115 ];
116
117 /**
118 * Constructor
119 */
120 public function __construct() {
121 $this->plugin_slug = self::SLUG;
122 $this->plugin_name = 'ThinkRank';
123 $this->plugin_file = 'thinkrank/thinkrank.php';
124 $this->meta_key_prefix = self::META_PREFIX;
125 // Settings are read from three stores (see export_settings()), not from
126 // a flat list of option keys, so this stays empty on purpose.
127 $this->option_keys = [];
128 }
129
130 /**
131 * ThinkRank's own data is always "detected" — we are the running plugin.
132 *
133 * @return bool
134 */
135 public function detect(): bool {
136 return true;
137 }
138
139 /**
140 * Every type a ThinkRank export can carry.
141 *
142 * Pro's data lives in its own tables (redirections, 404 logs, rank tracker),
143 * which the free plugin cannot read. Rather than
144 * leaving Pro users with a half-export, Pro registers its types here and
145 * supplies the records through `thinkrank_export_records`; the restore side
146 * hands them back through `thinkrank_restore_records`. Free ships the seam
147 * so Pro is not blocked on a follow-up release.
148 *
149 * @since 2.2.0
150 *
151 * @return string[] Type slugs, in export order.
152 */
153 public static function get_exportable_types(): array {
154 /**
155 * Filters the data types a ThinkRank export covers.
156 *
157 * A type added here must also answer `thinkrank_export_records` (to
158 * produce records) and `thinkrank_restore_records` (to apply them).
159 *
160 * @since 2.2.0
161 *
162 * @param string[] $types Type slugs, in export order.
163 */
164 $types = (array) apply_filters('thinkrank_export_types', self::CORE_TYPES);
165
166 return array_values(array_unique(array_filter($types, 'is_string')));
167 }
168
169 /**
170 * Get available data types with their record counts
171 *
172 * @return array Associative array of type => count
173 */
174 public function get_available_types(): array {
175 global $wpdb;
176
177 $counts = [];
178 $like = $wpdb->esc_like($this->meta_key_prefix) . '%';
179
180 // Mirror get_post_ids_with_meta()'s viewable-post-type filter, or the
181 // total reported here would not match the number of records the export
182 // actually emits.
183 $post_types = $this->get_exportable_post_types();
184 if (!empty($post_types)) {
185 $placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
186
187 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table names are $wpdb properties; every value is a placeholder replacement.
188 $sql = $wpdb->prepare(
189 "SELECT COUNT(DISTINCT pm.post_id)
190 FROM {$wpdb->postmeta} pm
191 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
192 WHERE pm.meta_key LIKE %s
193 AND p.post_type IN ({$placeholders})",
194 array_merge([$like], $post_types)
195 );
196 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
197
198 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared above.
199 $post_count = (int) $wpdb->get_var($sql);
200 if ($post_count > 0) {
201 $counts['postmeta'] = $post_count;
202 }
203 }
204
205 $term_count = (int) $wpdb->get_var(
206 $wpdb->prepare(
207 "SELECT COUNT(DISTINCT term_id) FROM {$wpdb->termmeta} WHERE meta_key LIKE %s",
208 $like
209 )
210 );
211 if ($term_count > 0) {
212 $counts['termmeta'] = $term_count;
213 }
214
215 // Counted here (unlike the source-plugin detector, which never counts
216 // usermeta) because the UI derives its type checkboxes from these
217 // counts — a type missing here is invisible to the user.
218 $user_count = (int) $wpdb->get_var(
219 $wpdb->prepare(
220 "SELECT COUNT(DISTINCT user_id) FROM {$wpdb->usermeta} WHERE meta_key LIKE %s",
221 $like
222 )
223 );
224 if ($user_count > 0) {
225 $counts['usermeta'] = $user_count;
226 }
227
228 // Settings always exist — Settings::get_all() falls back to defaults —
229 // so this type is always offered.
230 $counts['settings'] = 1;
231
232 /**
233 * Filters the record counts a ThinkRank export offers.
234 *
235 * The UI derives its type checkboxes from these, so a type with no
236 * count here is invisible even when a handler stands ready to export it.
237 *
238 * @since 2.2.0
239 *
240 * @param array<string,int> $counts Type slug => record count.
241 */
242 $counts = (array) apply_filters('thinkrank_export_type_counts', $counts);
243
244 // Keep the declared order, and drop anything that has nothing to say.
245 $ordered = [];
246 foreach (self::get_exportable_types() as $type) {
247 if (!empty($counts[$type])) {
248 $ordered[$type] = (int) $counts[$type];
249 }
250 }
251
252 return $ordered;
253 }
254
255 /**
256 * Export a page of post meta
257 *
258 * @param int $page Page number (1-indexed)
259 * @return array Array of records
260 */
261 protected function export_postmeta_page(int $page): array {
262 $records = [];
263
264 foreach ($this->get_post_ids_with_meta($page) as $post_id) {
265 $post_id = (int) $post_id;
266 $meta = $this->collect_meta($this->get_all_plugin_meta($post_id));
267
268 if (empty($meta)) {
269 continue;
270 }
271
272 $records[] = $this->build_record($post_id, 'post', $meta);
273 }
274
275 return $records;
276 }
277
278 /**
279 * Export a page of term meta
280 *
281 * @param int $page Page number (1-indexed)
282 * @return array Array of records
283 */
284 protected function export_termmeta_page(int $page): array {
285 $records = [];
286
287 foreach ($this->get_term_ids_with_meta($page) as $term_id) {
288 $term_id = (int) $term_id;
289 $meta = $this->collect_meta($this->get_all_plugin_term_meta($term_id));
290
291 if (empty($meta)) {
292 continue;
293 }
294
295 $records[] = $this->build_record($term_id, 'term', $meta);
296 }
297
298 return $records;
299 }
300
301 /**
302 * Export a page of user meta
303 *
304 * @param int $page Page number (1-indexed)
305 * @return array Array of records
306 */
307 protected function export_usermeta_page(int $page): array {
308 $records = [];
309
310 foreach ($this->get_user_ids_with_meta($page) as $user_id) {
311 $user_id = (int) $user_id;
312 $meta = $this->collect_meta($this->get_all_plugin_user_meta($user_id));
313
314 if (empty($meta)) {
315 continue;
316 }
317
318 $records[] = $this->build_record($user_id, 'user', $meta);
319 }
320
321 return $records;
322 }
323
324 /**
325 * Export every settings store as a single record.
326 *
327 * ThinkRank keeps settings in three places, and a backup that misses one of
328 * them restores cleanly while changing nothing on the site:
329 *
330 * - `thinkrank_{key}` options — Settings::$defaults, one option per key
331 * - the thinkrank_seo_settings table — what the FRONTEND actually reads
332 * - standalone aggregate options — written directly by feature managers
333 *
334 * @return array Array with a single settings record
335 */
336 protected function export_settings(): array {
337 return [
338 [
339 'type' => 'settings',
340 'source_plugin' => self::SLUG,
341 'data' => $this->redact_secrets([
342 'options' => $this->export_option_settings(),
343 'seo_table' => $this->export_seo_table_settings(),
344 'aggregate' => $this->export_aggregate_options(),
345 ]),
346 ],
347 ];
348 }
349
350 /**
351 * Strip every secret from the assembled payload, once.
352 *
353 * Redaction used to live inside export_option_settings() alone, so it
354 * protected the bucket it was written for and neither of the other two:
355 * the settings table and the aggregate options were serialized verbatim.
356 * Nothing said they shouldn't be, which meant the day any of them held a
357 * real credential it would leave the site silently.
358 *
359 * Doing it here instead of per-bucket means a bucket added later is
360 * covered by construction rather than by remembering.
361 *
362 * @param array $data The three settings buckets.
363 * @return array The same buckets with secrets removed.
364 */
365 private function redact_secrets(array $data): array {
366 $secret_keys = self::secret_setting_keys();
367
368 foreach (['options', 'seo_table', 'aggregate'] as $bucket) {
369 if (isset($data[$bucket]) && is_array($data[$bucket])) {
370 $data[$bucket] = $this->strip_secret_keys($data[$bucket], $secret_keys);
371 }
372 }
373
374 // Option-scoped secrets: only a secret because of where they sit.
375 foreach (self::SECRET_OPTION_KEYS as $option_name => $option_secrets) {
376 if (!isset($data['aggregate'][$option_name]) || !is_array($data['aggregate'][$option_name])) {
377 continue;
378 }
379
380 foreach ($option_secrets as $secret_key) {
381 unset($data['aggregate'][$option_name][$secret_key]);
382 }
383 }
384
385 return $data;
386 }
387
388 /**
389 * Remove any key named as a secret, at any depth.
390 *
391 * Recursive on purpose: the settings table nests the stored key three
392 * levels down (category → context → id → key), and a stored value can
393 * itself be an array. A secret is a secret wherever it turns up.
394 *
395 * @param array $data Data to filter.
396 * @param string[] $secret_keys Key names that must never be exported.
397 * @return array
398 */
399 private function strip_secret_keys(array $data, array $secret_keys): array {
400 foreach ($data as $key => $value) {
401 if (is_string($key) && in_array($key, $secret_keys, true)) {
402 unset($data[$key]);
403 continue;
404 }
405
406 if (is_array($value)) {
407 $data[$key] = $this->strip_secret_keys($value, $secret_keys);
408 }
409 }
410
411 return $data;
412 }
413
414 /**
415 * Redirections live in Pro's own store, so the free plugin has nothing of
416 * its own here — the records come from Pro through the extension filter.
417 *
418 * @param int $page Page number (1-indexed)
419 * @return array
420 */
421 protected function export_redirections_page(int $page): array {
422 return $this->export_extension_page('redirections', $page);
423 }
424
425 /**
426 * As above: Pro owns the 404 log.
427 *
428 * @param int $page Page number (1-indexed)
429 * @return array
430 */
431 protected function export_404_logs_page(int $page): array {
432 return $this->export_extension_page('404_logs', $page);
433 }
434
435 /**
436 * A type Pro registered through `thinkrank_export_types`.
437 *
438 * @param string $type Data type
439 * @param int $page Page number (1-indexed)
440 * @return array Records
441 */
442 protected function export_custom_type_page(string $type, int $page): array {
443 return $this->export_extension_page($type, $page);
444 }
445
446 /**
447 * Records for a type the free plugin does not own.
448 *
449 * @param string $type Data type
450 * @param int $page Page number (1-indexed)
451 * @return array Records
452 */
453 public function export_extension_page(string $type, int $page): array {
454 /**
455 * Filters the records a non-core export type contributes.
456 *
457 * Return the same record shape the core types use, and an empty array
458 * once the last page is reached — pagination stops when a page comes
459 * back shorter than the chunk size.
460 *
461 * @since 2.2.0
462 *
463 * @param array $records Records for this page (empty by default).
464 * @param string $type Data type being exported.
465 * @param int $page 1-based page number.
466 */
467 $records = apply_filters('thinkrank_export_records', [], $type, $page);
468
469 return is_array($records) ? $records : [];
470 }
471
472 /**
473 * Templates in our own data are already in ThinkRank's format and must
474 * round-trip untouched, so this is a pass-through — the only transform is
475 * the base class's coercion of non-string values.
476 *
477 * @param mixed $value Stored value
478 * @param int|null $post_id Unused; kept for the parent signature
479 * @return string
480 */
481 protected function convert_template_variables($value, ?int $post_id = null): string {
482 return $this->stringify_template_value($value);
483 }
484
485 /**
486 * Build a snapshot record.
487 *
488 * @param int $object_id Post / term / user ID
489 * @param string $object_type Object type
490 * @param array $meta Raw meta, key => value
491 * @return array
492 */
493 private function build_record(int $object_id, string $object_type, array $meta): array {
494 return [
495 'object_id' => $object_id,
496 'object_type' => $object_type,
497 'source_plugin' => self::SLUG,
498 'data' => $meta,
499 ];
500 }
501
502 /**
503 * Prepare raw meta rows for the snapshot.
504 *
505 * The `get_all_plugin_*_meta()` helpers return raw DB strings, so a
506 * serialized array arrives as its serialized STRING. Writing that back with
507 * update_post_meta() would store a string where an array used to be — the
508 * value looks right in the database and comes back wrong from
509 * get_post_meta(). Unserializing here keeps the round-trip exact and lets
510 * the JSON export hold real structure instead of a PHP-serialized blob.
511 *
512 * @param array $meta Raw meta_key => meta_value pairs
513 * @return array Prepared meta
514 */
515 private function collect_meta(array $meta): array {
516 /**
517 * Filter meta keys to leave out of a ThinkRank export.
518 *
519 * Everything under `_thinkrank_` is exported verbatim by default,
520 * including derived caches. Sites that would rather not carry those can
521 * drop them here.
522 *
523 * @since 2.2.0
524 *
525 * @param string[] $skip_keys Meta keys to omit.
526 */
527 $skip_keys = (array) apply_filters('thinkrank_export_skip_meta_keys', []);
528
529 $prepared = [];
530 foreach ($meta as $key => $value) {
531 if (in_array($key, $skip_keys, true)) {
532 continue;
533 }
534
535 $prepared[$key] = is_serialized($value)
536 ? Safe_Unserializer::unserialize($value, $value)
537 : $value;
538 }
539
540 return $prepared;
541 }
542
543 /**
544 * Export the `thinkrank_{key}` options behind Settings.
545 *
546 * Secrets are removed rather than offered behind an opt-in. Beyond being a
547 * plain-text credential leak in a file users pass around, encrypted values
548 * carry the `trenc:v1:` marker and are derived from the site's auth salts —
549 * they could not be decrypted after a restore onto another site anyway.
550 *
551 * The stripping itself is not done here: redact_secrets() runs over all
552 * three buckets once, so this returns the raw store.
553 *
554 * @return array Setting key => value
555 */
556 private function export_option_settings(): array {
557 return Settings::instance()->get_all();
558 }
559
560 /**
561 * Settings keys that must never appear in an export.
562 *
563 * Public and static because Snapshot_Migrator needs the same list to undo
564 * the redaction on the way back in: an aggregate option is restored whole,
565 * so every key stripped here has to be carried forward from the receiving
566 * site or the restore deletes it.
567 *
568 * @return string[]
569 */
570 public static function secret_setting_keys(): array {
571 return array_values(
572 array_unique(
573 array_merge(Settings::instance()->get_encrypted_keys(), self::EXTRA_SECRET_KEYS)
574 )
575 );
576 }
577
578 /**
579 * Export every row of the thinkrank_seo_settings table.
580 *
581 * Read straight from the table rather than through each feature manager's
582 * get_settings(), which merges in defaults — a backup wants the rows that
583 * are actually stored, and this way a category we forgot to enumerate (or
584 * one added later) is still captured.
585 *
586 * @return array Category => context type => context id => [key => value]
587 */
588 private function export_seo_table_settings(): array {
589 global $wpdb;
590
591 $table = $wpdb->prefix . 'thinkrank_seo_settings';
592
593 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal.
594 $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
595 if ($exists !== $table) {
596 return [];
597 }
598
599 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- one-off export read of our own table; caching a full-table dump would be worse than the query.
600 $rows = $wpdb->get_results(
601 "SELECT context_type, context_id, setting_category, setting_key, setting_value, setting_type, priority
602 FROM `{$table}`
603 WHERE is_active = 1
604 ORDER BY setting_category ASC, context_type ASC, context_id ASC, setting_key ASC",
605 ARRAY_A
606 );
607 // phpcs:enable
608
609 $settings = [];
610 foreach ((array) $rows as $row) {
611 $category = (string) $row['setting_category'];
612 $context_type = (string) $row['context_type'];
613 $context_id = (int) $row['context_id'];
614
615 $settings[$category][$context_type][$context_id][$row['setting_key']] = [
616 'value' => maybe_unserialize($row['setting_value']),
617 'type' => $row['setting_type'],
618 'priority' => (int) $row['priority'],
619 ];
620 }
621
622 return $settings;
623 }
624
625 /**
626 * Export the standalone aggregate settings options.
627 *
628 * @return array Option name => value
629 */
630 private function export_aggregate_options(): array {
631 $options = [];
632
633 foreach (self::AGGREGATE_OPTIONS as $option_name) {
634 $value = get_option($option_name, null);
635 if ($value === null || $value === false) {
636 continue;
637 }
638
639 $options[$option_name] = $value;
640 }
641
642 return $options;
643 }
644 }
645