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

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