PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
2.13.0 2.12.0 2.11.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 All 54 releases
thinkrank / includes / admin / importers / class-squirrly-exporter.php

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

1,400 lines 52.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Squirrly SEO Exporter
5 *
6 * Reads Squirrly SEO ("GEO Plugin by Squirrly SEO") data and normalizes it
7 * into the canonical snapshot format.
8 *
9 * Squirrly keeps per-URL SEO in its own `{prefix}qss` table, NOT in postmeta
10 * (#740). Each row carries the object it belongs to in a serialized `post`
11 * column ({ID, post_type, term_id, taxonomy}) and the SEO values in a
12 * serialized `seo` column (SQ_Models_Domain_Sq::toArray()). One table holds
13 * posts, terms, author profiles and the homepage, so every paged reader below
14 * walks the same table and emits only the rows of its own kind.
15 *
16 * Settings live in one option, `sq_options`, stored as a JSON string. Title
17 * and description templates use Squirrly's `{{token}}` syntax.
18 *
19 * Squirrly's cloud data (Briefcase keywords, Focus Pages, Rankings, Audits,
20 * AI Visibility) never touches the site's database and cannot be exported.
21 *
22 * @package ThinkRank\Admin\Importers
23 * @since 2.8.0
24 */
25
26 declare(strict_types=1);
27
28 namespace ThinkRank\Admin\Importers;
29
30 if (!defined('ABSPATH')) {
31 exit;
32 }
33
34 /**
35 * Squirrly SEO Exporter Class
36 *
37 * @since 2.8.0
38 */
39 class Squirrly_Exporter extends Abstract_Plugin_Exporter {
40
41 /**
42 * Source plugin slug.
43 */
44 public const SLUG = 'squirrly';
45
46 /**
47 * Squirrly's per-URL SEO table (without the WordPress prefix).
48 */
49 private const TABLE = 'qss';
50
51 /**
52 * Advanced Pack redirects table (without the WordPress prefix).
53 */
54 private const REDIRECTS_TABLE = 'qss_redirects';
55
56 /**
57 * Squirrly `jsonld_types` slug => ThinkRank schema type.
58 *
59 * ThinkRank's supported types come from Schema_Settings_Config::
60 * get_supported_schema_types() (PascalCase). Slugs with no equivalent map
61 * to '' so the migrator's empty-skip leaves no invalid value behind.
62 */
63 private const SCHEMA_TYPE_MAP = [
64 'article' => 'Article',
65 'newsarticle' => 'Article',
66 'blogposting' => 'Article',
67 'product' => 'Product',
68 'website' => 'WebSite',
69 'organization' => 'Organization',
70 'person' => 'Person',
71 'event' => 'Event',
72 'review' => 'Review',
73 'video' => 'VideoObject',
74 'videoobject' => 'VideoObject',
75 'faq' => 'FAQPage',
76 'faqpage' => 'FAQPage',
77 'howto' => 'HowTo',
78 'service' => 'LocalBusiness',
79 'local' => 'LocalBusiness',
80 'localbusiness' => 'LocalBusiness',
81 'localstore' => 'LocalBusiness',
82 'localrestaurant' => 'LocalBusiness',
83 'software' => 'SoftwareApplication',
84 'softwareapplication' => 'SoftwareApplication',
85 ];
86
87 /**
88 * Pattern keys in `sq_options.patterns` that describe a context rather
89 * than a post type, so they never become per-post-type templates.
90 */
91 private const NON_POST_TYPE_PATTERNS = ['home', 'category', 'tag', 'shop', 'profile', 'archive', 'search', '404', 'custom'];
92
93 /**
94 * Decoded `sq_options`, cached for the request.
95 *
96 * @var array|null
97 */
98 private ?array $options_cache = null;
99
100 /**
101 * Row-kind counts, cached for the request.
102 *
103 * @var array|null
104 */
105 private ?array $kind_counts = null;
106
107 /**
108 * Constructor
109 */
110 public function __construct() {
111 $this->plugin_slug = self::SLUG;
112 $this->plugin_name = 'Squirrly SEO';
113 $this->plugin_file = 'squirrly-seo/squirrly.php';
114 // Squirrly only writes a handful of fallback keys to postmeta
115 // (_sq_keywords, _sq_description, _sq_jsonld_*); the SEO itself is in
116 // the qss table. The prefix exists for cleanup and the meta fallbacks.
117 $this->meta_key_prefix = '_sq_';
118 $this->option_keys = ['sq_options'];
119 }
120
121 /**
122 * {@inheritDoc}
123 */
124 public function detect(): bool {
125 if ($this->table_exists() && $this->count_rows() > 0) {
126 return true;
127 }
128
129 return get_option('sq_options', null) !== null;
130 }
131
132 /**
133 * {@inheritDoc}
134 */
135 public function get_available_types(): array {
136 $types = [];
137 $counts = $this->get_kind_counts();
138
139 if (!empty($counts['post'])) {
140 $types['postmeta'] = $counts['post'];
141 }
142 if (!empty($counts['term'])) {
143 $types['termmeta'] = $counts['term'];
144 }
145 if (!empty($counts['user'])) {
146 $types['usermeta'] = $counts['user'];
147 }
148
149 $redirects = $this->count_redirects();
150 if ($redirects > 0) {
151 $types['redirections'] = $redirects;
152 }
153
154 if (get_option('sq_options', null) !== null) {
155 $types['settings'] = 1;
156 }
157
158 return $types;
159 }
160
161 /**
162 * {@inheritDoc}
163 */
164 protected function export_postmeta_page(int $page): array {
165 $records = [];
166
167 foreach ($this->get_rows_page($page) as $row) {
168 $parsed = $this->classify_row($row);
169 if ($parsed['kind'] !== 'post') {
170 continue;
171 }
172
173 $post_id = $parsed['object_id'];
174 $seo = $parsed['seo'];
175
176 // Squirrly falls back to these postmeta keys when the row is
177 // empty (SQ_Models_Domain_Sq), so honour the same precedence.
178 $description = $this->seo_string($seo, 'description');
179 if ($description === '') {
180 $description = (string) get_post_meta($post_id, '_sq_description', true);
181 }
182 $keywords_raw = $this->seo_string($seo, 'keywords');
183 if ($keywords_raw === '') {
184 $keywords_raw = (string) get_post_meta($post_id, '_sq_keywords', true);
185 }
186 $focus_keywords = $this->split_keywords($keywords_raw);
187 $robots = $this->extract_robots($seo);
188
189 $redirect_url = $this->seo_string($seo, 'redirect');
190
191 $records[] = [
192 'object_id' => $post_id,
193 'object_type' => 'post',
194 'source_plugin' => $this->plugin_slug,
195 'data' => [
196 'seo_title' => $this->convert_post_value($this->seo_string($seo, 'title'), $post_id),
197 'meta_description' => $this->convert_post_value($description, $post_id),
198 'focus_keyword' => $focus_keywords[0] ?? '',
199 'focus_keywords' => $focus_keywords,
200 'canonical_url' => $this->seo_string($seo, 'canonical'),
201 'noindex' => $this->seo_flag($seo, 'noindex'),
202 'nofollow' => $this->seo_flag($seo, 'nofollow'),
203 'noarchive' => $robots['noarchive'],
204 'noimageindex' => $robots['noimageindex'],
205 'nosnippet' => $robots['nosnippet'],
206 'og_title' => $this->convert_post_value($this->seo_string($seo, 'og_title'), $post_id),
207 'og_description' => $this->convert_post_value($this->seo_string($seo, 'og_description'), $post_id),
208 'og_image' => $this->seo_string($seo, 'og_media'),
209 'twitter_title' => $this->convert_post_value($this->seo_string($seo, 'tw_title'), $post_id),
210 'twitter_description' => $this->convert_post_value($this->seo_string($seo, 'tw_description'), $post_id),
211 'twitter_image' => $this->seo_string($seo, 'tw_media'),
212 'primary_category' => (int) ($seo['primary_category'] ?? 0),
213 'schema_type' => $this->map_schema_type($seo['jsonld_types'] ?? null),
214 // Squirrly's `focuspage` marks a page as one of the site's
215 // important ones — the same concept Yoast calls cornerstone
216 // and ThinkRank calls pillar content. Squirrly's own
217 // ImportExport maps it to `yst_is_cornerstone`, so this
218 // follows the source plugin's reading of its own field.
219 // The Focus Pages *audit* stays on Squirrly's servers and
220 // is not importable; the flag itself is local to the row.
221 'pillar_content' => $this->seo_flag($seo, 'focuspage'),
222 ],
223 'extended' => [
224 'is_cornerstone' => $this->seo_flag($seo, 'focuspage') === 1,
225 'focus_keywords_additional' => array_slice($focus_keywords, 1),
226 'redirect_enabled' => $redirect_url !== '',
227 'redirect_url' => $redirect_url,
228 'redirect_type' => (string) ((int) ($seo['redirect_type'] ?? 301) ?: 301),
229 'exclude_from_sitemap' => $this->seo_flag($seo, 'nositemap') === 1,
230 'og_type' => $this->seo_string($seo, 'og_type'),
231 'twitter_card_type' => $this->seo_string($seo, 'tw_type'),
232 'squirrly_enabled' => !isset($seo['doseo']) || (int) $seo['doseo'] !== 0,
233 ],
234 ];
235 }
236
237 return $records;
238 }
239
240 /**
241 * {@inheritDoc}
242 */
243 protected function export_termmeta_page(int $page): array {
244 $records = [];
245
246 foreach ($this->get_rows_page($page) as $row) {
247 $parsed = $this->classify_row($row);
248 if ($parsed['kind'] !== 'term') {
249 continue;
250 }
251
252 $seo = $parsed['seo'];
253 $term = get_term($parsed['object_id'], $parsed['taxonomy']);
254 $term = ($term instanceof \WP_Term) ? $term : null;
255
256 $records[] = [
257 'object_id' => $parsed['object_id'],
258 'object_type' => 'term',
259 'source_plugin' => $this->plugin_slug,
260 'data' => [
261 'seo_title' => $this->convert_term_value($this->seo_string($seo, 'title'), $term),
262 'meta_description' => $this->convert_term_value($this->seo_string($seo, 'description'), $term),
263 'canonical_url' => $this->seo_string($seo, 'canonical'),
264 'noindex' => $this->seo_flag($seo, 'noindex'),
265 'nofollow' => $this->seo_flag($seo, 'nofollow'),
266 'og_title' => $this->convert_term_value($this->seo_string($seo, 'og_title'), $term),
267 'og_description' => $this->convert_term_value($this->seo_string($seo, 'og_description'), $term),
268 ],
269 'extended' => [
270 'taxonomy' => $parsed['taxonomy'],
271 'og_image' => $this->seo_string($seo, 'og_media'),
272 'twitter_title' => $this->seo_string($seo, 'tw_title'),
273 'twitter_description' => $this->seo_string($seo, 'tw_description'),
274 'twitter_image' => $this->seo_string($seo, 'tw_media'),
275 ],
276 ];
277 }
278
279 return $records;
280 }
281
282 /**
283 * {@inheritDoc}
284 *
285 * Squirrly stores author-archive SEO as `profile` rows in the same table
286 * (ID = user id). The title/description go in canonical `data` so the
287 * plugin-agnostic migrator writes the USER meta Author_Archives_Manager
288 * reads; ThinkRank consumes those verbatim, so tokens resolve to literals
289 * here with {{name}} seeded from the display name.
290 */
291 protected function export_usermeta_page(int $page): array {
292 $records = [];
293
294 foreach ($this->get_rows_page($page) as $row) {
295 $parsed = $this->classify_row($row);
296 if ($parsed['kind'] !== 'user') {
297 continue;
298 }
299
300 $user_id = $parsed['object_id'];
301 $seo = $parsed['seo'];
302 $display_name = (string) get_the_author_meta('display_name', $user_id);
303
304 $title = str_replace(['{{name}}', '{{ name }}'], $display_name, $this->seo_string($seo, 'title'));
305 $desc = str_replace(['{{name}}', '{{ name }}'], $display_name, $this->seo_string($seo, 'description'));
306
307 if ($title === '' && $desc === '') {
308 continue;
309 }
310
311 $records[] = [
312 'object_id' => $user_id,
313 'object_type' => 'user',
314 'source_plugin' => $this->plugin_slug,
315 'data' => [
316 'seo_title' => $this->convert_template_variables($title),
317 'meta_description' => $this->convert_template_variables($desc),
318 ],
319 'extended' => [
320 'author_noindex' => $this->seo_flag($seo, 'noindex') === 1,
321 ],
322 ];
323 }
324
325 return $records;
326 }
327
328 /**
329 * {@inheritDoc}
330 */
331 protected function export_settings(): array {
332 $opts = $this->get_options();
333 $patterns = is_array($opts['patterns'] ?? null) ? $opts['patterns'] : [];
334 $socials = is_array($opts['socials'] ?? null) ? $opts['socials'] : [];
335 $codes = is_array($opts['codes'] ?? null) ? $opts['codes'] : [];
336 $jsonld = is_array($opts['sq_jsonld'] ?? null) ? $opts['sq_jsonld'] : [];
337 $organization = is_array($jsonld['Organization'] ?? null) ? $jsonld['Organization'] : [];
338 $person = is_array($jsonld['Person'] ?? null) ? $jsonld['Person'] : [];
339
340 // The homepage row in the qss table wins over the home pattern: it is
341 // what the owner typed for the homepage specifically.
342 $home = $this->get_home_seo();
343 $home_pattern = is_array($patterns['home'] ?? null) ? $patterns['home'] : [];
344 $homepage_title = $this->seo_string($home, 'title');
345 if ($homepage_title === '') {
346 $homepage_title = (string) ($home_pattern['title'] ?? '');
347 }
348 $homepage_description = $this->seo_string($home, 'description');
349 if ($homepage_description === '') {
350 $homepage_description = (string) ($home_pattern['description'] ?? '');
351 }
352
353 $post_pattern = is_array($patterns['post'] ?? null) ? $patterns['post'] : [];
354 $separator = trim((string) ($post_pattern['sep'] ?? ($home_pattern['sep'] ?? '|')));
355
356 $indexnow_key = $opts['indexnow_key'] ?? '';
357 $indexnow_types = is_array($opts['indexnow_post_type'] ?? null)
358 ? array_values(array_map('strval', $opts['indexnow_post_type']))
359 : [];
360
361 return [
362 [
363 'type' => 'settings',
364 'source_plugin' => $this->plugin_slug,
365 'data' => [
366 'separator' => $separator !== '' ? $separator : '|',
367 'homepage_title' => $this->convert_template_variables($homepage_title),
368 'homepage_description' => $this->convert_template_variables($homepage_description),
369 'organization_name' => $this->convert_template_variables((string) ($organization['name'] ?? '')),
370 'organization_logo' => (string) ($organization['logo']['url'] ?? ''),
371 'knowledge_graph' => $this->extract_knowledge_graph($opts, $organization, $person),
372 'social_profiles' => [
373 'facebook' => (string) ($socials['facebook_site'] ?? ''),
374 'twitter' => (string) ($socials['twitter_site'] ?? ''),
375 'instagram' => (string) ($socials['instagram_url'] ?? ''),
376 'linkedin' => (string) ($socials['linkedin_url'] ?? ''),
377 'youtube' => (string) ($socials['youtube_url'] ?? ''),
378 'pinterest' => (string) ($socials['pinterest_url'] ?? ''),
379 ],
380 'noindex_archives' => [
381 'date' => !empty($patterns['archive']['noindex']),
382 'author' => !empty($patterns['profile']['noindex']),
383 ],
384 'twitter_card_type' => (string) ($socials['twitter_card_type'] ?? ''),
385 'social_defaults' => [
386 'facebook_app_id' => (string) ($socials['fbadminapp'] ?? ''),
387 'og_default_image' => (string) ($opts['sq_og_image'] ?? ''),
388 ],
389 'instant_indexing' => [
390 'api_key' => is_string($indexnow_key) ? $indexnow_key : '',
391 ],
392 ],
393 'extended' => [
394 // Pinterest is applied (ThinkRank renders it); the rest is
395 // preserved and gates /import/cleanup until #631 renders it.
396 'webmaster_tools' => array_filter([
397 'google' => (string) ($codes['google_wt'] ?? ''),
398 'bing' => (string) ($codes['bing_wt'] ?? ''),
399 'yandex' => (string) ($codes['yandex_wt'] ?? ''),
400 'baidu' => (string) ($codes['baidu_wt'] ?? ''),
401 'pinterest' => (string) ($codes['pinterest_verify'] ?? ''),
402 ]),
403 'title_formats' => $this->extract_title_formats($patterns),
404 'post_type_settings' => $this->extract_post_type_settings($patterns),
405 'author_archives' => $this->extract_author_archives($patterns),
406 'local_seo' => $this->extract_local_seo($opts, $organization),
407 'sitemap_settings' => $this->normalize_sitemap($opts),
408 'instant_indexing_post_types' => $indexnow_types,
409 'focus_pages' => $this->fetch_focus_pages(),
410 ],
411 ],
412 ];
413 }
414
415 /**
416 * The post ids Squirrly has marked as Focus Pages.
417 *
418 * These are the one part of the migration that is not in the database.
419 * Squirrly keeps the list on its own servers — `getFocusPages()` is a live
420 * call to `api/posts/focus` — and writes nothing locally: the `focuspage`
421 * field on a `qss` row is declared but never populated, and the only local
422 * trace is an `sq_auditpage_<cloud id>` transient keyed by Squirrly's id
423 * rather than WordPress's. So a site that switches to ThinkRank loses the
424 * selection entirely unless it is read while Squirrly is still installed
425 * and connected, which is exactly when an import runs.
426 *
427 * The response carries `post_id`, the local WordPress id, so no URL
428 * matching is needed. Credentials are Squirrly's to handle and never
429 * touch the snapshot; only the resulting ids are stored.
430 *
431 * Everything here is best-effort: no Squirrly, no connection, an API
432 * error or a changed response shape all yield an empty list and the rest
433 * of the settings export carries on.
434 *
435 * @return int[] Local post ids, in the order Squirrly returned them.
436 */
437 private function fetch_focus_pages(): array {
438 if (!class_exists('SQ_Classes_ObjController')) {
439 return [];
440 }
441
442 try {
443 // Squirrly autoloads its classes through this controller; the
444 // remote controller is not loaded until something asks for it.
445 \SQ_Classes_ObjController::getClass('SQ_Classes_RemoteController');
446
447 if (!class_exists('SQ_Classes_RemoteController')
448 || !method_exists('SQ_Classes_RemoteController', 'getFocusPages')) {
449 return [];
450 }
451
452 $response = \SQ_Classes_RemoteController::getFocusPages();
453 } catch (\Throwable $e) {
454 return [];
455 }
456
457 if (is_wp_error($response) || empty($response)) {
458 return [];
459 }
460
461 $ids = [];
462 foreach ((array) $response as $page) {
463 $post_id = (int) (((array) $page)['post_id'] ?? 0);
464
465 // A focus page for content that has since been deleted, or for
466 // another site on the same Squirrly account, has nothing to point
467 // at here.
468 if ($post_id > 0 && get_post($post_id) !== null) {
469 $ids[] = $post_id;
470 }
471 }
472
473 return array_values(array_unique($ids));
474 }
475
476 /**
477 * {@inheritDoc}
478 *
479 * Advanced Pack redirects (`{prefix}qss_redirects`, a Redirection-plugin
480 * lineage schema): `url` is the source path, `action_data` the target,
481 * `action_code` the status, `regex` the flag, `status` enabled/disabled.
482 * Only `url` actions become redirects; `error` (serve a 404) and `pass`
483 * have no ThinkRank equivalent.
484 */
485 protected function export_redirections_page(int $page): array {
486 if (!$this->table_exists(self::REDIRECTS_TABLE)) {
487 return [];
488 }
489
490 global $wpdb;
491
492 $table = $wpdb->prefix . self::REDIRECTS_TABLE;
493 $offset = ($page - 1) * $this->chunk_size;
494
495 $rows = $wpdb->get_results(
496 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
497 $wpdb->prepare("SELECT * FROM {$table} ORDER BY id ASC LIMIT %d OFFSET %d", $this->chunk_size, $offset),
498 ARRAY_A
499 );
500
501 $this->last_page_row_count = is_array($rows) ? count($rows) : 0;
502
503 if (empty($rows)) {
504 return [];
505 }
506
507 $records = [];
508 foreach ($rows as $row) {
509 $record = $this->map_redirect_row((array) $row);
510 if ($record !== null) {
511 $records[] = $record;
512 }
513 }
514
515 return $records;
516 }
517
518 /**
519 * Map one qss_redirects row onto the canonical redirection record.
520 *
521 * @param array $row Table row
522 * @return array|null Record, or null when the row is not a URL redirect
523 */
524 private function map_redirect_row(array $row): ?array {
525 if (($row['action_type'] ?? 'url') !== 'url') {
526 return null;
527 }
528
529 $source = trim((string) ($row['url'] ?? ''));
530 $target = $this->extract_redirect_target($row['action_data'] ?? '');
531
532 if ($source === '' || $target === '') {
533 return null;
534 }
535
536 $code = (int) ($row['action_code'] ?? 301);
537 if (!in_array($code, [301, 302, 303, 307, 308], true)) {
538 $code = 301;
539 }
540
541 return [
542 'object_type' => 'redirection',
543 'source_plugin' => $this->plugin_slug,
544 'data' => [],
545 'extended' => [
546 'source_url' => $source,
547 'target_url' => $target,
548 'http_code' => $code,
549 'is_regex' => !empty($row['regex']),
550 'enabled' => ($row['status'] ?? 'enabled') === 'enabled',
551 'hits' => (int) ($row['last_count'] ?? 0),
552 ],
553 ];
554 }
555
556 /**
557 * `action_data` is a plain target for simple rules and a JSON/serialized
558 * object ({url: …} or {url_from, url_notfrom}) for conditional ones.
559 *
560 * @param mixed $data Raw action_data
561 * @return string Target URL or ''
562 */
563 private function extract_redirect_target($data): string {
564 if (is_array($data)) {
565 return (string) ($data['url'] ?? ($data['url_from'] ?? ''));
566 }
567 $data = trim((string) $data);
568 if ($data === '') {
569 return '';
570 }
571 if ($data[0] === '{' || $data[0] === '[') {
572 $decoded = json_decode($data, true);
573 if (is_array($decoded)) {
574 return (string) ($decoded['url'] ?? ($decoded['url_from'] ?? ''));
575 }
576 }
577 if (is_serialized($data)) {
578 $decoded = Safe_Unserializer::to_array($data);
579 return (string) ($decoded['url'] ?? ($decoded['url_from'] ?? ''));
580 }
581 return $data;
582 }
583
584 /**
585 * {@inheritDoc}
586 *
587 * Squirrly's `{{token}}` syntax resolved to literals for a stored value.
588 * Per-post tokens need a post; site tokens resolve anywhere. Unknown
589 * tokens are stripped so nothing renders literally after import.
590 */
591 protected function convert_template_variables($value, ?int $post_id = null): string {
592 // Foreign data first: booleans/arrays in the source plugin's options
593 // must degrade to '' here, not fatal the migration (see abstract).
594 $value = $this->stringify_template_value($value);
595
596 if ($value === '' || strpos($value, '{{') === false) {
597 return $value;
598 }
599
600 $replacements = [
601 'sitename' => get_bloginfo('name'),
602 'sitedesc' => get_bloginfo('description'),
603 'sep' => '-',
604 'page' => '',
605 'keyword' => '',
606 'searchphrase' => '',
607 'plural' => '',
608 'url' => '',
609 ];
610
611 if ($post_id) {
612 $post = get_post($post_id);
613 if ($post) {
614 $replacements['title'] = $post->post_title;
615 $replacements['excerpt'] = \ThinkRank\Core\Seo_Text::trim_words(
616 $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words(wp_strip_all_tags($post->post_content), 55),
617 55
618 );
619 $replacements['date'] = get_the_date('', $post);
620 $replacements['name'] = get_the_author_meta('display_name', (int) $post->post_author);
621 $replacements['url'] = (string) get_permalink($post);
622
623 $categories = get_the_category($post_id);
624 $replacements['category'] = !empty($categories) ? $categories[0]->name : '';
625 $replacements['category_description'] = !empty($categories) ? (string) $categories[0]->description : '';
626
627 $tags = get_the_tags($post_id);
628 $replacements['tag'] = !empty($tags) ? $tags[0]->name : '';
629 }
630 }
631
632 $value = (string) preg_replace_callback(
633 '/\{\{\s*([a-z0-9_]+)\s*\}\}/i',
634 static function (array $m) use ($replacements): string {
635 $token = strtolower($m[1]);
636 return array_key_exists($token, $replacements) ? (string) $replacements[$token] : '';
637 },
638 $value
639 );
640
641 $value = (string) preg_replace('/\s{2,}/', ' ', $value);
642
643 return trim($value);
644 }
645
646 /**
647 * Squirrly per-post variables ThinkRank resolves per request (#886): kept
648 * as tags rather than frozen into text. Mirrors convert_global_pattern().
649 */
650 private const POST_TOKENS = [
651 'title' => '%title%',
652 'sitename' => '%sitename%',
653 'sep' => '%sep%',
654 'excerpt' => '%excerpt%',
655 'date' => '%date%',
656 'name' => '%author%',
657 'category' => '%category%',
658 ];
659
660 /**
661 * Squirrly per-term variables ThinkRank resolves on a term archive —
662 * the ones convert_term_template() seeds with the term's own values.
663 */
664 private const TERM_TOKENS = [
665 'term_title' => '%term%',
666 'title' => '%term%',
667 'category' => '%term%',
668 'tag' => '%term%',
669 'category_description' => '%excerpt%',
670 'excerpt' => '%excerpt%',
671 'sitename' => '%sitename%',
672 'sep' => '%sep%',
673 ];
674
675 /**
676 * A per-post Squirrly value: mapped variables as ThinkRank tags, the rest literal.
677 *
678 * @param string $value Raw Squirrly value
679 * @param int $post_id Post ID
680 * @return string
681 */
682 private function convert_post_value(string $value, int $post_id): string {
683 return $this->tokenize_object_template(
684 $value,
685 self::brace_token_patterns(self::POST_TOKENS),
686 fn(string $rest): string => $this->convert_template_variables($rest, $post_id)
687 );
688 }
689
690 /**
691 * A per-term Squirrly value: mapped variables as ThinkRank tags, the rest literal.
692 *
693 * @param string $value Raw Squirrly value
694 * @param \WP_Term|null $term The term, when it still exists
695 * @return string
696 */
697 private function convert_term_value(string $value, ?\WP_Term $term): string {
698 return $this->tokenize_object_template(
699 $value,
700 self::brace_token_patterns(self::TERM_TOKENS),
701 fn(string $rest): string => $this->convert_term_template($rest, $term)
702 );
703 }
704
705 /**
706 * Resolve a term-level value: the generic converter only knows post
707 * context, so {{term_title}}/{{title}}/{{category}}/{{tag}} are seeded
708 * with the term name and {{category_description}} with its description
709 * first. Without this a category title of "{{term_title}} archive" would
710 * import as " archive" — a silent loss, not a literal token.
711 *
712 * @param string $value Raw stored value
713 * @param \WP_Term|null $term The term, when it still exists
714 * @return string Resolved value
715 */
716 private function convert_term_template(string $value, ?\WP_Term $term): string {
717 if ($value === '' || strpos($value, '{{') === false) {
718 return trim($value);
719 }
720
721 if ($term) {
722 $seeded = [
723 'term_title' => $term->name,
724 'title' => $term->name,
725 'category' => $term->name,
726 'tag' => $term->name,
727 'category_description' => (string) $term->description,
728 'excerpt' => (string) $term->description,
729 ];
730 $value = (string) preg_replace_callback(
731 '/\{\{\s*(term_title|title|category|tag|category_description|excerpt)\s*\}\}/i',
732 static function (array $m) use ($seeded): string {
733 return $seeded[strtolower($m[1])] ?? '';
734 },
735 $value
736 );
737 }
738
739 return $this->convert_template_variables($value);
740 }
741
742 // -------------------------------------------------------------------------
743 // Settings helpers
744 // -------------------------------------------------------------------------
745
746 /**
747 * Squirrly's per-context patterns onto ThinkRank's Site Identity keys, in
748 * the identity renderer's %token% vocabulary.
749 *
750 * @param array $patterns sq_options.patterns
751 * @return array Map of ThinkRank title-format key => converted template
752 */
753 private function extract_title_formats(array $patterns): array {
754 $sources = [
755 'homepage_title' => ['home', ''],
756 'post_title' => ['post', '%post_title%'],
757 'page_title' => ['page', '%page_title%'],
758 'category_title' => ['category', '%category_title%'],
759 'tag_title' => ['tag', '%tag_title%'],
760 'search_title' => ['search', '%search_term%'],
761 'archive_title' => ['archive', '%archive_title%'],
762 'author_title' => ['profile', '%author_name%'],
763 ];
764
765 $formats = [];
766 foreach ($sources as $tr_key => [$pattern_key, $context_token]) {
767 $raw = (string) ($patterns[$pattern_key]['title'] ?? '');
768 if ($raw === '') {
769 continue;
770 }
771 $converted = $this->convert_identity_pattern($raw, $context_token);
772 if ($converted !== '') {
773 $formats[$tr_key] = $converted;
774 }
775 }
776
777 return $formats;
778 }
779
780 /**
781 * Convert a Squirrly pattern into ThinkRank's Site Identity token
782 * vocabulary, preserving structure. Tokens ThinkRank cannot resolve are
783 * stripped and a separator left dangling by that strip is dropped.
784 *
785 * @param string $template Raw Squirrly pattern ({{token}} syntax)
786 * @param string $context_token Token {{title}}/{{term_title}} stands for (may be '')
787 * @return string ThinkRank Site Identity template
788 */
789 private function convert_identity_pattern(string $template, string $context_token): string {
790 if ($template === '' || strpos($template, '{{') === false) {
791 return trim($template);
792 }
793
794 $map = [
795 'sitename' => '%site_title%',
796 'sitedesc' => '%site_description%',
797 'sep' => '%sep%',
798 'date' => '%date%',
799 'searchphrase' => '%search_term%',
800 'name' => '%author_name%',
801 'category' => '%category_title%',
802 'tag' => '%tag_title%',
803 ];
804 if ($context_token !== '') {
805 $map['title'] = $context_token;
806 $map['term_title'] = $context_token;
807 }
808
809 $template = (string) preg_replace_callback(
810 '/\{\{\s*([a-z0-9_]+)\s*\}\}/i',
811 static function (array $m) use ($map): string {
812 $token = strtolower($m[1]);
813 return $map[$token] ?? '';
814 },
815 $template
816 );
817
818 $template = (string) preg_replace('/\s{2,}/', ' ', $template);
819 $template = trim($template);
820 $template = (string) preg_replace('/^(?:%sep%)\s*/', '', $template);
821 $template = (string) preg_replace('/\s*(?:%sep%)$/', '', $template);
822
823 return trim($template);
824 }
825
826 /**
827 * Convert a Squirrly pattern into the Global SEO Pattern_Resolver
828 * vocabulary (%title%/%sitename%/%sep%/%excerpt%).
829 *
830 * @param string $template Raw Squirrly pattern
831 * @return string Converted template
832 */
833 private function convert_global_pattern(string $template): string {
834 if ($template === '' || strpos($template, '{{') === false) {
835 return trim($template);
836 }
837
838 $map = [
839 'title' => '%title%',
840 'sitename' => '%sitename%',
841 'sep' => '%sep%',
842 'excerpt' => '%excerpt%',
843 'date' => '%date%',
844 'name' => '%author%',
845 'category' => '%category%',
846 ];
847
848 $template = (string) preg_replace_callback(
849 '/\{\{\s*([a-z0-9_]+)\s*\}\}/i',
850 static function (array $m) use ($map): string {
851 return $map[strtolower($m[1])] ?? '';
852 },
853 $template
854 );
855
856 $template = (string) preg_replace('/\s+/', ' ', $template);
857
858 return trim($template);
859 }
860
861 /**
862 * Per-post-type title/description templates and robots in the shape
863 * migrate_post_type_settings() consumes. Squirrly keys patterns by post
864 * type slug; context keys (home, category, tax-*, …) are skipped and the
865 * migrator drops any slug that isn't a registered post type.
866 *
867 * @param array $patterns sq_options.patterns
868 * @return array Map of post_type => {title_template, description_template, custom_robots, robots}
869 */
870 private function extract_post_type_settings(array $patterns): array {
871 $settings = [];
872
873 foreach ($patterns as $key => $pattern) {
874 $key = (string) $key;
875 if (!is_array($pattern) || in_array($key, self::NON_POST_TYPE_PATTERNS, true) || strpos($key, 'tax-') === 0) {
876 continue;
877 }
878
879 $pt_settings = [];
880
881 $title = $this->convert_global_pattern((string) ($pattern['title'] ?? ''));
882 if ($title !== '') {
883 $pt_settings['title_template'] = $title;
884 }
885
886 $description = $this->convert_global_pattern((string) ($pattern['description'] ?? ''));
887 if ($description !== '') {
888 $pt_settings['description_template'] = $description;
889 }
890
891 $directives = [];
892 foreach (['noindex', 'nofollow'] as $flag) {
893 if (!empty($pattern[$flag])) {
894 $directives[] = $flag;
895 }
896 }
897 if (!empty($directives)) {
898 $pt_settings['custom_robots'] = true;
899 $pt_settings['robots'] = $directives;
900 }
901
902 if (!empty($pt_settings)) {
903 $settings[$key] = $pt_settings;
904 }
905 }
906
907 return $settings;
908 }
909
910 /**
911 * Author archive behaviour from the `profile` pattern. The noindex flag
912 * travels in data.noindex_archives.
913 *
914 * @param array $patterns sq_options.patterns
915 * @return array Author archive settings
916 */
917 private function extract_author_archives(array $patterns): array {
918 $profile = is_array($patterns['profile'] ?? null) ? $patterns['profile'] : [];
919 if (empty($profile)) {
920 return [];
921 }
922
923 return [
924 'enabled' => empty($profile['noindex']),
925 'title' => $this->convert_identity_pattern((string) ($profile['title'] ?? ''), '%author_name%'),
926 'description' => $this->convert_identity_pattern((string) ($profile['description'] ?? ''), '%author_name%'),
927 ];
928 }
929
930 /**
931 * Knowledge Graph entity from sq_jsonld_type + the matching sq_jsonld block.
932 *
933 * @param array $opts Decoded sq_options
934 * @param array $organization sq_jsonld.Organization
935 * @param array $person sq_jsonld.Person
936 * @return array{type: string, name: string}
937 */
938 private function extract_knowledge_graph(array $opts, array $organization, array $person): array {
939 $type = strtolower((string) ($opts['sq_jsonld_type'] ?? 'Organization'));
940 if ($type === 'person') {
941 $name = $this->convert_template_variables((string) ($person['name'] ?? ''));
942 return $name === '' ? ['type' => '', 'name' => ''] : ['type' => 'person', 'name' => $name];
943 }
944
945 $name = $this->convert_template_variables((string) ($organization['name'] ?? ''));
946 if ($name === '') {
947 return ['type' => '', 'name' => ''];
948 }
949
950 // Any local-business subtype is still an organization for the graph.
951 return ['type' => 'organization', 'name' => $name];
952 }
953
954 /**
955 * Local SEO in the canonical shape migrate_settings() reads, only when
956 * Squirrly's Local SEO JSON-LD is switched on. The NAP lives in the
957 * Organization block; hours and price range in sq_jsonld_local.
958 *
959 * @param array $opts Decoded sq_options
960 * @param array $organization sq_jsonld.Organization
961 * @return array Canonical local_seo payload, or [] when off
962 */
963 private function extract_local_seo(array $opts, array $organization): array {
964 if (empty($opts['sq_auto_jsonld_local'])) {
965 return [];
966 }
967
968 $local = is_array($opts['sq_jsonld_local'] ?? null) ? $opts['sq_jsonld_local'] : [];
969 $address = is_array($organization['address'] ?? null) ? $organization['address'] : [];
970 $geo = is_array($organization['place']['geo'] ?? null) ? $organization['place']['geo'] : [];
971
972 $type = (string) ($opts['sq_jsonld_type'] ?? '');
973 if ($type === '' || in_array(strtolower($type), ['organization', 'person'], true)) {
974 $type = 'LocalBusiness';
975 }
976
977 $address_out = array_filter([
978 'street' => (string) ($address['streetAddress'] ?? ''),
979 'city' => (string) ($address['addressLocality'] ?? ''),
980 'state' => (string) ($address['addressRegion'] ?? ''),
981 'postal_code' => (string) ($address['postalCode'] ?? ''),
982 'country' => (string) ($address['addressCountry'] ?? ''),
983 ]);
984
985 $geo_out = [];
986 if (($geo['latitude'] ?? '') !== '' && ($geo['longitude'] ?? '') !== '') {
987 $geo_out = [
988 'latitude' => (string) $geo['latitude'],
989 'longitude' => (string) $geo['longitude'],
990 ];
991 }
992
993 return [
994 'business_type' => $type,
995 'business_name' => $this->convert_template_variables((string) ($organization['name'] ?? '')),
996 'phone' => (string) ($organization['contactPoint']['telephone'] ?? ''),
997 'address' => $address_out,
998 'geo' => $geo_out,
999 'price_range' => (string) ($local['priceRange'] ?? ''),
1000 'opening_hours' => $this->extract_opening_hours($local),
1001 ];
1002 }
1003
1004 /**
1005 * sq_jsonld_local.openingHoursSpecification ({dayOfWeek, opens, closes}
1006 * rows) onto ThinkRank's lowercase-day map. Days without both times are
1007 * skipped rather than written as closed: Squirrly leaves them blank by
1008 * default, which says nothing about the business.
1009 *
1010 * @param array $local sq_jsonld_local
1011 * @return array Map of day => {open, close, closed}
1012 */
1013 private function extract_opening_hours(array $local): array {
1014 $spec = $local['openingHoursSpecification'] ?? [];
1015 if (!is_array($spec)) {
1016 return [];
1017 }
1018
1019 $valid_days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
1020 $result = [];
1021
1022 foreach ($spec as $entry) {
1023 if (!is_array($entry)) {
1024 continue;
1025 }
1026 $day = strtolower((string) ($entry['dayOfWeek'] ?? ''));
1027 $opens = trim((string) ($entry['opens'] ?? ''));
1028 $closes = trim((string) ($entry['closes'] ?? ''));
1029 if (!in_array($day, $valid_days, true) || isset($result[$day]) || $opens === '' || $closes === '') {
1030 continue;
1031 }
1032 $result[$day] = [
1033 'open' => $opens,
1034 'close' => $closes,
1035 'closed' => false,
1036 ];
1037 }
1038
1039 return $result;
1040 }
1041
1042 /**
1043 * Sitemap keys onto the flat canonical `sitemap_settings` shape.
1044 * `sq_sitemap` maps `sitemap-<type>` => [filename, enabled].
1045 *
1046 * @param array $opts Decoded sq_options
1047 * @return array Canonical sitemap_settings payload
1048 */
1049 private function normalize_sitemap(array $opts): array {
1050 if (!array_key_exists('sq_auto_sitemap', $opts) && !array_key_exists('sq_sitemap', $opts)) {
1051 return ['has_data' => false];
1052 }
1053
1054 $files = is_array($opts['sq_sitemap'] ?? null) ? $opts['sq_sitemap'] : [];
1055 $show = is_array($opts['sq_sitemap_show'] ?? null) ? $opts['sq_sitemap_show'] : [];
1056
1057 $included = static function (string $key) use ($files): bool {
1058 $entry = $files[$key] ?? null;
1059 return is_array($entry) && !empty($entry[1]);
1060 };
1061
1062 $result = [
1063 'enabled' => !empty($opts['sq_auto_sitemap']),
1064 'include_images' => !empty($show['images']),
1065 'include_posts' => $included('sitemap-post'),
1066 'include_pages' => $included('sitemap-page'),
1067 'include_categories' => $included('sitemap-category'),
1068 'include_tags' => $included('sitemap-post_tag'),
1069 'ping_search_engines' => !empty($opts['sq_sitemap_ping']),
1070 'has_data' => true,
1071 ];
1072
1073 $per_page = (int) ($opts['sq_sitemap_perpage'] ?? 0);
1074 if ($per_page > 0) {
1075 $result['links_per_sitemap'] = $per_page;
1076 }
1077
1078 return $result;
1079 }
1080
1081 // -------------------------------------------------------------------------
1082 // Row helpers
1083 // -------------------------------------------------------------------------
1084
1085 /**
1086 * Classify one qss row by its serialized `post` column.
1087 *
1088 * @param array $row Table row (URL, post, seo)
1089 * @return array{kind: string, object_id: int, post_type: string, taxonomy: string, seo: array}
1090 */
1091 private function classify_row(array $row): array {
1092 $post = Safe_Unserializer::to_array($row['post'] ?? '');
1093 $seo = Safe_Unserializer::to_array($row['seo'] ?? '');
1094
1095 $id = (int) ($post['ID'] ?? 0);
1096 $post_type = (string) ($post['post_type'] ?? '');
1097 $term_id = (int) ($post['term_id'] ?? 0);
1098 $taxonomy = (string) ($post['taxonomy'] ?? '');
1099
1100 $kind = 'other';
1101 $object_id = 0;
1102
1103 if ($term_id > 0 && $taxonomy !== '') {
1104 $kind = 'term';
1105 $object_id = $term_id;
1106 } elseif ($post_type === 'profile' && $id > 0) {
1107 $kind = 'user';
1108 $object_id = $id;
1109 } elseif ($post_type === 'home') {
1110 $kind = 'home';
1111 } elseif ($id > 0) {
1112 $kind = 'post';
1113 $object_id = $id;
1114 }
1115
1116 return [
1117 'kind' => $kind,
1118 'object_id' => $object_id,
1119 'post_type' => $post_type,
1120 'taxonomy' => $taxonomy,
1121 'seo' => $seo,
1122 ];
1123 }
1124
1125 /**
1126 * A string field from the seo array; Squirrly stores NULL for anything
1127 * never set.
1128 *
1129 * @param array $seo Parsed seo array
1130 * @param string $key Field
1131 * @return string
1132 */
1133 private function seo_string(array $seo, string $key): string {
1134 $value = $seo[$key] ?? '';
1135 if (is_array($value) || is_object($value)) {
1136 return '';
1137 }
1138 return trim((string) $value);
1139 }
1140
1141 /**
1142 * A 0/1 flag from the seo array.
1143 *
1144 * @param array $seo Parsed seo array
1145 * @param string $key Field
1146 * @return int
1147 */
1148 private function seo_flag(array $seo, string $key): int {
1149 return !empty($seo[$key]) ? 1 : 0;
1150 }
1151
1152 /**
1153 * Comma-separated keywords => trimmed, de-duplicated list.
1154 *
1155 * @param string $raw Raw keywords string
1156 * @return string[]
1157 */
1158 private function split_keywords(string $raw): array {
1159 if ($raw === '') {
1160 return [];
1161 }
1162 $keywords = array_map('trim', explode(',', $raw));
1163 $keywords = array_filter($keywords, static fn($keyword) => $keyword !== '');
1164 return array_values(array_unique($keywords));
1165 }
1166
1167 /**
1168 * Advanced robots directives from the seo `robots` field, which may be an
1169 * array of directive names or a comma-separated string.
1170 *
1171 * @param array $seo Parsed seo array
1172 * @return array{noarchive: int, noimageindex: int, nosnippet: int}
1173 */
1174 private function extract_robots(array $seo): array {
1175 $raw = $seo['robots'] ?? null;
1176 $directives = [];
1177 if (is_array($raw)) {
1178 $directives = array_map('strval', $raw);
1179 } elseif (is_string($raw) && $raw !== '') {
1180 $directives = array_map('trim', explode(',', $raw));
1181 }
1182 $directives = array_map('strtolower', $directives);
1183
1184 return [
1185 'noarchive' => in_array('noarchive', $directives, true) ? 1 : 0,
1186 'noimageindex' => in_array('noimageindex', $directives, true) ? 1 : 0,
1187 'nosnippet' => in_array('nosnippet', $directives, true) ? 1 : 0,
1188 ];
1189 }
1190
1191 /**
1192 * Squirrly `jsonld_types` (array of slugs, or one slug) => ThinkRank type.
1193 * The first slug with a ThinkRank equivalent wins; `website` on a post is
1194 * Squirrly's default rather than a choice, so it only counts when nothing
1195 * else is set.
1196 *
1197 * @param mixed $types Raw jsonld_types
1198 * @return string ThinkRank schema type or ''
1199 */
1200 private function map_schema_type($types): string {
1201 if (is_string($types)) {
1202 $types = [$types];
1203 }
1204 if (!is_array($types)) {
1205 return '';
1206 }
1207
1208 $fallback = '';
1209 foreach ($types as $type) {
1210 $key = strtolower(trim((string) $type));
1211 if ($key === '' || !isset(self::SCHEMA_TYPE_MAP[$key])) {
1212 continue;
1213 }
1214 if ($key === 'website') {
1215 $fallback = self::SCHEMA_TYPE_MAP[$key];
1216 continue;
1217 }
1218 return self::SCHEMA_TYPE_MAP[$key];
1219 }
1220
1221 return $fallback;
1222 }
1223
1224 // -------------------------------------------------------------------------
1225 // Storage helpers
1226 // -------------------------------------------------------------------------
1227
1228 /**
1229 * Decoded `sq_options`. Squirrly stores it as a JSON string; older
1230 * versions used a serialized array.
1231 *
1232 * @return array
1233 */
1234 private function get_options(): array {
1235 if ($this->options_cache !== null) {
1236 return $this->options_cache;
1237 }
1238
1239 $raw = get_option('sq_options', []);
1240 if (is_string($raw)) {
1241 $decoded = json_decode($raw, true);
1242 if (!is_array($decoded)) {
1243 $decoded = is_serialized($raw) ? Safe_Unserializer::to_array($raw) : [];
1244 }
1245 $raw = $decoded;
1246 }
1247
1248 $this->options_cache = is_array($raw) ? $raw : [];
1249
1250 return $this->options_cache;
1251 }
1252
1253 /**
1254 * Whether a Squirrly table exists.
1255 *
1256 * @param string $suffix Table suffix without prefix
1257 * @return bool
1258 */
1259 private function table_exists(string $suffix = self::TABLE): bool {
1260 global $wpdb;
1261
1262 $table = $wpdb->prefix . $suffix;
1263
1264 return (bool) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
1265 }
1266
1267 /**
1268 * Total qss rows for this blog.
1269 *
1270 * @return int
1271 */
1272 private function count_rows(): int {
1273 global $wpdb;
1274
1275 $table = $wpdb->prefix . self::TABLE;
1276
1277 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
1278 return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE blog_id = %d", get_current_blog_id()));
1279 }
1280
1281 /**
1282 * Advanced Pack redirect rows that are URL redirects.
1283 *
1284 * @return int
1285 */
1286 private function count_redirects(): int {
1287 if (!$this->table_exists(self::REDIRECTS_TABLE)) {
1288 return 0;
1289 }
1290
1291 global $wpdb;
1292
1293 $table = $wpdb->prefix . self::REDIRECTS_TABLE;
1294
1295 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal.
1296 return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE action_type = 'url'");
1297 }
1298
1299 /**
1300 * Row counts per kind (post/term/user/home), from the `post` column only.
1301 *
1302 * @return array
1303 */
1304 private function get_kind_counts(): array {
1305 if ($this->kind_counts !== null) {
1306 return $this->kind_counts;
1307 }
1308
1309 $counts = ['post' => 0, 'term' => 0, 'user' => 0, 'home' => 0, 'other' => 0];
1310
1311 if ($this->table_exists()) {
1312 global $wpdb;
1313
1314 $table = $wpdb->prefix . self::TABLE;
1315 $rows = $wpdb->get_results(
1316 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
1317 $wpdb->prepare("SELECT post FROM {$table} WHERE blog_id = %d", get_current_blog_id()),
1318 ARRAY_A
1319 );
1320
1321 foreach ((array) $rows as $row) {
1322 $kind = $this->classify_row(['post' => $row['post'] ?? '', 'seo' => ''])['kind'];
1323 $counts[$kind]++;
1324 }
1325 }
1326
1327 $this->kind_counts = $counts;
1328
1329 return $counts;
1330 }
1331
1332 /**
1333 * One page of qss rows, in id order. Sets last_page_row_count so
1334 * export_chunk() paginates on rows fetched, not records emitted (a page
1335 * of term rows yields no post records but is not the end).
1336 *
1337 * @param int $page Page number (1-indexed)
1338 * @return array Rows
1339 */
1340 private function get_rows_page(int $page): array {
1341 if (!$this->table_exists()) {
1342 $this->last_page_row_count = 0;
1343 return [];
1344 }
1345
1346 global $wpdb;
1347
1348 $table = $wpdb->prefix . self::TABLE;
1349 $offset = ($page - 1) * $this->chunk_size;
1350
1351 $rows = $wpdb->get_results(
1352 $wpdb->prepare(
1353 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
1354 "SELECT id, URL, post, seo FROM {$table} WHERE blog_id = %d ORDER BY id ASC LIMIT %d OFFSET %d",
1355 get_current_blog_id(),
1356 $this->chunk_size,
1357 $offset
1358 ),
1359 ARRAY_A
1360 );
1361
1362 $this->last_page_row_count = is_array($rows) ? count($rows) : 0;
1363
1364 return is_array($rows) ? $rows : [];
1365 }
1366
1367 /**
1368 * The homepage row's seo array, when Squirrly has one.
1369 *
1370 * @return array
1371 */
1372 private function get_home_seo(): array {
1373 if (!$this->table_exists()) {
1374 return [];
1375 }
1376
1377 global $wpdb;
1378
1379 $table = $wpdb->prefix . self::TABLE;
1380 $rows = $wpdb->get_results(
1381 $wpdb->prepare(
1382 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
1383 "SELECT post, seo FROM {$table} WHERE blog_id = %d AND post LIKE %s LIMIT 5",
1384 get_current_blog_id(),
1385 '%' . $wpdb->esc_like('"home"') . '%'
1386 ),
1387 ARRAY_A
1388 );
1389
1390 foreach ((array) $rows as $row) {
1391 $parsed = $this->classify_row($row);
1392 if ($parsed['kind'] === 'home') {
1393 return $parsed['seo'];
1394 }
1395 }
1396
1397 return [];
1398 }
1399 }
1400