PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.2.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.2.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 1.1.0 1.10.0 All 48 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.2.0, at includes/admin/importers/class-thinkrank-exporter.php

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