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-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.9.0, at includes/admin/importers/class-squirrly-exporter.php

1,341 lines 50.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_template_variables($this->seo_string($seo, 'title'), $post_id),
197 'meta_description' => $this->convert_template_variables($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_template_variables($this->seo_string($seo, 'og_title'), $post_id),
207 'og_description' => $this->convert_template_variables($this->seo_string($seo, 'og_description'), $post_id),
208 'og_image' => $this->seo_string($seo, 'og_media'),
209 'twitter_title' => $this->convert_template_variables($this->seo_string($seo, 'tw_title'), $post_id),
210 'twitter_description' => $this->convert_template_variables($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_template($this->seo_string($seo, 'title'), $term),
262 'meta_description' => $this->convert_term_template($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_template($this->seo_string($seo, 'og_title'), $term),
267 'og_description' => $this->convert_term_template($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 * Resolve a term-level value: the generic converter only knows post
648 * context, so {{term_title}}/{{title}}/{{category}}/{{tag}} are seeded
649 * with the term name and {{category_description}} with its description
650 * first. Without this a category title of "{{term_title}} archive" would
651 * import as " archive" — a silent loss, not a literal token.
652 *
653 * @param string $value Raw stored value
654 * @param \WP_Term|null $term The term, when it still exists
655 * @return string Resolved value
656 */
657 private function convert_term_template(string $value, ?\WP_Term $term): string {
658 if ($value === '' || strpos($value, '{{') === false) {
659 return trim($value);
660 }
661
662 if ($term) {
663 $seeded = [
664 'term_title' => $term->name,
665 'title' => $term->name,
666 'category' => $term->name,
667 'tag' => $term->name,
668 'category_description' => (string) $term->description,
669 'excerpt' => (string) $term->description,
670 ];
671 $value = (string) preg_replace_callback(
672 '/\{\{\s*(term_title|title|category|tag|category_description|excerpt)\s*\}\}/i',
673 static function (array $m) use ($seeded): string {
674 return $seeded[strtolower($m[1])] ?? '';
675 },
676 $value
677 );
678 }
679
680 return $this->convert_template_variables($value);
681 }
682
683 // -------------------------------------------------------------------------
684 // Settings helpers
685 // -------------------------------------------------------------------------
686
687 /**
688 * Squirrly's per-context patterns onto ThinkRank's Site Identity keys, in
689 * the identity renderer's %token% vocabulary.
690 *
691 * @param array $patterns sq_options.patterns
692 * @return array Map of ThinkRank title-format key => converted template
693 */
694 private function extract_title_formats(array $patterns): array {
695 $sources = [
696 'homepage_title' => ['home', ''],
697 'post_title' => ['post', '%post_title%'],
698 'page_title' => ['page', '%page_title%'],
699 'category_title' => ['category', '%category_title%'],
700 'tag_title' => ['tag', '%tag_title%'],
701 'search_title' => ['search', '%search_term%'],
702 'archive_title' => ['archive', '%archive_title%'],
703 'author_title' => ['profile', '%author_name%'],
704 ];
705
706 $formats = [];
707 foreach ($sources as $tr_key => [$pattern_key, $context_token]) {
708 $raw = (string) ($patterns[$pattern_key]['title'] ?? '');
709 if ($raw === '') {
710 continue;
711 }
712 $converted = $this->convert_identity_pattern($raw, $context_token);
713 if ($converted !== '') {
714 $formats[$tr_key] = $converted;
715 }
716 }
717
718 return $formats;
719 }
720
721 /**
722 * Convert a Squirrly pattern into ThinkRank's Site Identity token
723 * vocabulary, preserving structure. Tokens ThinkRank cannot resolve are
724 * stripped and a separator left dangling by that strip is dropped.
725 *
726 * @param string $template Raw Squirrly pattern ({{token}} syntax)
727 * @param string $context_token Token {{title}}/{{term_title}} stands for (may be '')
728 * @return string ThinkRank Site Identity template
729 */
730 private function convert_identity_pattern(string $template, string $context_token): string {
731 if ($template === '' || strpos($template, '{{') === false) {
732 return trim($template);
733 }
734
735 $map = [
736 'sitename' => '%site_title%',
737 'sitedesc' => '%site_description%',
738 'sep' => '%sep%',
739 'date' => '%date%',
740 'searchphrase' => '%search_term%',
741 'name' => '%author_name%',
742 'category' => '%category_title%',
743 'tag' => '%tag_title%',
744 ];
745 if ($context_token !== '') {
746 $map['title'] = $context_token;
747 $map['term_title'] = $context_token;
748 }
749
750 $template = (string) preg_replace_callback(
751 '/\{\{\s*([a-z0-9_]+)\s*\}\}/i',
752 static function (array $m) use ($map): string {
753 $token = strtolower($m[1]);
754 return $map[$token] ?? '';
755 },
756 $template
757 );
758
759 $template = (string) preg_replace('/\s{2,}/', ' ', $template);
760 $template = trim($template);
761 $template = (string) preg_replace('/^(?:%sep%)\s*/', '', $template);
762 $template = (string) preg_replace('/\s*(?:%sep%)$/', '', $template);
763
764 return trim($template);
765 }
766
767 /**
768 * Convert a Squirrly pattern into the Global SEO Pattern_Resolver
769 * vocabulary (%title%/%sitename%/%sep%/%excerpt%).
770 *
771 * @param string $template Raw Squirrly pattern
772 * @return string Converted template
773 */
774 private function convert_global_pattern(string $template): string {
775 if ($template === '' || strpos($template, '{{') === false) {
776 return trim($template);
777 }
778
779 $map = [
780 'title' => '%title%',
781 'sitename' => '%sitename%',
782 'sep' => '%sep%',
783 'excerpt' => '%excerpt%',
784 'date' => '%date%',
785 'name' => '%author%',
786 'category' => '%category%',
787 ];
788
789 $template = (string) preg_replace_callback(
790 '/\{\{\s*([a-z0-9_]+)\s*\}\}/i',
791 static function (array $m) use ($map): string {
792 return $map[strtolower($m[1])] ?? '';
793 },
794 $template
795 );
796
797 $template = (string) preg_replace('/\s+/', ' ', $template);
798
799 return trim($template);
800 }
801
802 /**
803 * Per-post-type title/description templates and robots in the shape
804 * migrate_post_type_settings() consumes. Squirrly keys patterns by post
805 * type slug; context keys (home, category, tax-*, …) are skipped and the
806 * migrator drops any slug that isn't a registered post type.
807 *
808 * @param array $patterns sq_options.patterns
809 * @return array Map of post_type => {title_template, description_template, custom_robots, robots}
810 */
811 private function extract_post_type_settings(array $patterns): array {
812 $settings = [];
813
814 foreach ($patterns as $key => $pattern) {
815 $key = (string) $key;
816 if (!is_array($pattern) || in_array($key, self::NON_POST_TYPE_PATTERNS, true) || strpos($key, 'tax-') === 0) {
817 continue;
818 }
819
820 $pt_settings = [];
821
822 $title = $this->convert_global_pattern((string) ($pattern['title'] ?? ''));
823 if ($title !== '') {
824 $pt_settings['title_template'] = $title;
825 }
826
827 $description = $this->convert_global_pattern((string) ($pattern['description'] ?? ''));
828 if ($description !== '') {
829 $pt_settings['description_template'] = $description;
830 }
831
832 $directives = [];
833 foreach (['noindex', 'nofollow'] as $flag) {
834 if (!empty($pattern[$flag])) {
835 $directives[] = $flag;
836 }
837 }
838 if (!empty($directives)) {
839 $pt_settings['custom_robots'] = true;
840 $pt_settings['robots'] = $directives;
841 }
842
843 if (!empty($pt_settings)) {
844 $settings[$key] = $pt_settings;
845 }
846 }
847
848 return $settings;
849 }
850
851 /**
852 * Author archive behaviour from the `profile` pattern. The noindex flag
853 * travels in data.noindex_archives.
854 *
855 * @param array $patterns sq_options.patterns
856 * @return array Author archive settings
857 */
858 private function extract_author_archives(array $patterns): array {
859 $profile = is_array($patterns['profile'] ?? null) ? $patterns['profile'] : [];
860 if (empty($profile)) {
861 return [];
862 }
863
864 return [
865 'enabled' => empty($profile['noindex']),
866 'title' => $this->convert_identity_pattern((string) ($profile['title'] ?? ''), '%author_name%'),
867 'description' => $this->convert_identity_pattern((string) ($profile['description'] ?? ''), '%author_name%'),
868 ];
869 }
870
871 /**
872 * Knowledge Graph entity from sq_jsonld_type + the matching sq_jsonld block.
873 *
874 * @param array $opts Decoded sq_options
875 * @param array $organization sq_jsonld.Organization
876 * @param array $person sq_jsonld.Person
877 * @return array{type: string, name: string}
878 */
879 private function extract_knowledge_graph(array $opts, array $organization, array $person): array {
880 $type = strtolower((string) ($opts['sq_jsonld_type'] ?? 'Organization'));
881 if ($type === 'person') {
882 $name = $this->convert_template_variables((string) ($person['name'] ?? ''));
883 return $name === '' ? ['type' => '', 'name' => ''] : ['type' => 'person', 'name' => $name];
884 }
885
886 $name = $this->convert_template_variables((string) ($organization['name'] ?? ''));
887 if ($name === '') {
888 return ['type' => '', 'name' => ''];
889 }
890
891 // Any local-business subtype is still an organization for the graph.
892 return ['type' => 'organization', 'name' => $name];
893 }
894
895 /**
896 * Local SEO in the canonical shape migrate_settings() reads, only when
897 * Squirrly's Local SEO JSON-LD is switched on. The NAP lives in the
898 * Organization block; hours and price range in sq_jsonld_local.
899 *
900 * @param array $opts Decoded sq_options
901 * @param array $organization sq_jsonld.Organization
902 * @return array Canonical local_seo payload, or [] when off
903 */
904 private function extract_local_seo(array $opts, array $organization): array {
905 if (empty($opts['sq_auto_jsonld_local'])) {
906 return [];
907 }
908
909 $local = is_array($opts['sq_jsonld_local'] ?? null) ? $opts['sq_jsonld_local'] : [];
910 $address = is_array($organization['address'] ?? null) ? $organization['address'] : [];
911 $geo = is_array($organization['place']['geo'] ?? null) ? $organization['place']['geo'] : [];
912
913 $type = (string) ($opts['sq_jsonld_type'] ?? '');
914 if ($type === '' || in_array(strtolower($type), ['organization', 'person'], true)) {
915 $type = 'LocalBusiness';
916 }
917
918 $address_out = array_filter([
919 'street' => (string) ($address['streetAddress'] ?? ''),
920 'city' => (string) ($address['addressLocality'] ?? ''),
921 'state' => (string) ($address['addressRegion'] ?? ''),
922 'postal_code' => (string) ($address['postalCode'] ?? ''),
923 'country' => (string) ($address['addressCountry'] ?? ''),
924 ]);
925
926 $geo_out = [];
927 if (($geo['latitude'] ?? '') !== '' && ($geo['longitude'] ?? '') !== '') {
928 $geo_out = [
929 'latitude' => (string) $geo['latitude'],
930 'longitude' => (string) $geo['longitude'],
931 ];
932 }
933
934 return [
935 'business_type' => $type,
936 'business_name' => $this->convert_template_variables((string) ($organization['name'] ?? '')),
937 'phone' => (string) ($organization['contactPoint']['telephone'] ?? ''),
938 'address' => $address_out,
939 'geo' => $geo_out,
940 'price_range' => (string) ($local['priceRange'] ?? ''),
941 'opening_hours' => $this->extract_opening_hours($local),
942 ];
943 }
944
945 /**
946 * sq_jsonld_local.openingHoursSpecification ({dayOfWeek, opens, closes}
947 * rows) onto ThinkRank's lowercase-day map. Days without both times are
948 * skipped rather than written as closed: Squirrly leaves them blank by
949 * default, which says nothing about the business.
950 *
951 * @param array $local sq_jsonld_local
952 * @return array Map of day => {open, close, closed}
953 */
954 private function extract_opening_hours(array $local): array {
955 $spec = $local['openingHoursSpecification'] ?? [];
956 if (!is_array($spec)) {
957 return [];
958 }
959
960 $valid_days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
961 $result = [];
962
963 foreach ($spec as $entry) {
964 if (!is_array($entry)) {
965 continue;
966 }
967 $day = strtolower((string) ($entry['dayOfWeek'] ?? ''));
968 $opens = trim((string) ($entry['opens'] ?? ''));
969 $closes = trim((string) ($entry['closes'] ?? ''));
970 if (!in_array($day, $valid_days, true) || isset($result[$day]) || $opens === '' || $closes === '') {
971 continue;
972 }
973 $result[$day] = [
974 'open' => $opens,
975 'close' => $closes,
976 'closed' => false,
977 ];
978 }
979
980 return $result;
981 }
982
983 /**
984 * Sitemap keys onto the flat canonical `sitemap_settings` shape.
985 * `sq_sitemap` maps `sitemap-<type>` => [filename, enabled].
986 *
987 * @param array $opts Decoded sq_options
988 * @return array Canonical sitemap_settings payload
989 */
990 private function normalize_sitemap(array $opts): array {
991 if (!array_key_exists('sq_auto_sitemap', $opts) && !array_key_exists('sq_sitemap', $opts)) {
992 return ['has_data' => false];
993 }
994
995 $files = is_array($opts['sq_sitemap'] ?? null) ? $opts['sq_sitemap'] : [];
996 $show = is_array($opts['sq_sitemap_show'] ?? null) ? $opts['sq_sitemap_show'] : [];
997
998 $included = static function (string $key) use ($files): bool {
999 $entry = $files[$key] ?? null;
1000 return is_array($entry) && !empty($entry[1]);
1001 };
1002
1003 $result = [
1004 'enabled' => !empty($opts['sq_auto_sitemap']),
1005 'include_images' => !empty($show['images']),
1006 'include_posts' => $included('sitemap-post'),
1007 'include_pages' => $included('sitemap-page'),
1008 'include_categories' => $included('sitemap-category'),
1009 'include_tags' => $included('sitemap-post_tag'),
1010 'ping_search_engines' => !empty($opts['sq_sitemap_ping']),
1011 'has_data' => true,
1012 ];
1013
1014 $per_page = (int) ($opts['sq_sitemap_perpage'] ?? 0);
1015 if ($per_page > 0) {
1016 $result['links_per_sitemap'] = $per_page;
1017 }
1018
1019 return $result;
1020 }
1021
1022 // -------------------------------------------------------------------------
1023 // Row helpers
1024 // -------------------------------------------------------------------------
1025
1026 /**
1027 * Classify one qss row by its serialized `post` column.
1028 *
1029 * @param array $row Table row (URL, post, seo)
1030 * @return array{kind: string, object_id: int, post_type: string, taxonomy: string, seo: array}
1031 */
1032 private function classify_row(array $row): array {
1033 $post = Safe_Unserializer::to_array($row['post'] ?? '');
1034 $seo = Safe_Unserializer::to_array($row['seo'] ?? '');
1035
1036 $id = (int) ($post['ID'] ?? 0);
1037 $post_type = (string) ($post['post_type'] ?? '');
1038 $term_id = (int) ($post['term_id'] ?? 0);
1039 $taxonomy = (string) ($post['taxonomy'] ?? '');
1040
1041 $kind = 'other';
1042 $object_id = 0;
1043
1044 if ($term_id > 0 && $taxonomy !== '') {
1045 $kind = 'term';
1046 $object_id = $term_id;
1047 } elseif ($post_type === 'profile' && $id > 0) {
1048 $kind = 'user';
1049 $object_id = $id;
1050 } elseif ($post_type === 'home') {
1051 $kind = 'home';
1052 } elseif ($id > 0) {
1053 $kind = 'post';
1054 $object_id = $id;
1055 }
1056
1057 return [
1058 'kind' => $kind,
1059 'object_id' => $object_id,
1060 'post_type' => $post_type,
1061 'taxonomy' => $taxonomy,
1062 'seo' => $seo,
1063 ];
1064 }
1065
1066 /**
1067 * A string field from the seo array; Squirrly stores NULL for anything
1068 * never set.
1069 *
1070 * @param array $seo Parsed seo array
1071 * @param string $key Field
1072 * @return string
1073 */
1074 private function seo_string(array $seo, string $key): string {
1075 $value = $seo[$key] ?? '';
1076 if (is_array($value) || is_object($value)) {
1077 return '';
1078 }
1079 return trim((string) $value);
1080 }
1081
1082 /**
1083 * A 0/1 flag from the seo array.
1084 *
1085 * @param array $seo Parsed seo array
1086 * @param string $key Field
1087 * @return int
1088 */
1089 private function seo_flag(array $seo, string $key): int {
1090 return !empty($seo[$key]) ? 1 : 0;
1091 }
1092
1093 /**
1094 * Comma-separated keywords => trimmed, de-duplicated list.
1095 *
1096 * @param string $raw Raw keywords string
1097 * @return string[]
1098 */
1099 private function split_keywords(string $raw): array {
1100 if ($raw === '') {
1101 return [];
1102 }
1103 $keywords = array_map('trim', explode(',', $raw));
1104 $keywords = array_filter($keywords, static fn($keyword) => $keyword !== '');
1105 return array_values(array_unique($keywords));
1106 }
1107
1108 /**
1109 * Advanced robots directives from the seo `robots` field, which may be an
1110 * array of directive names or a comma-separated string.
1111 *
1112 * @param array $seo Parsed seo array
1113 * @return array{noarchive: int, noimageindex: int, nosnippet: int}
1114 */
1115 private function extract_robots(array $seo): array {
1116 $raw = $seo['robots'] ?? null;
1117 $directives = [];
1118 if (is_array($raw)) {
1119 $directives = array_map('strval', $raw);
1120 } elseif (is_string($raw) && $raw !== '') {
1121 $directives = array_map('trim', explode(',', $raw));
1122 }
1123 $directives = array_map('strtolower', $directives);
1124
1125 return [
1126 'noarchive' => in_array('noarchive', $directives, true) ? 1 : 0,
1127 'noimageindex' => in_array('noimageindex', $directives, true) ? 1 : 0,
1128 'nosnippet' => in_array('nosnippet', $directives, true) ? 1 : 0,
1129 ];
1130 }
1131
1132 /**
1133 * Squirrly `jsonld_types` (array of slugs, or one slug) => ThinkRank type.
1134 * The first slug with a ThinkRank equivalent wins; `website` on a post is
1135 * Squirrly's default rather than a choice, so it only counts when nothing
1136 * else is set.
1137 *
1138 * @param mixed $types Raw jsonld_types
1139 * @return string ThinkRank schema type or ''
1140 */
1141 private function map_schema_type($types): string {
1142 if (is_string($types)) {
1143 $types = [$types];
1144 }
1145 if (!is_array($types)) {
1146 return '';
1147 }
1148
1149 $fallback = '';
1150 foreach ($types as $type) {
1151 $key = strtolower(trim((string) $type));
1152 if ($key === '' || !isset(self::SCHEMA_TYPE_MAP[$key])) {
1153 continue;
1154 }
1155 if ($key === 'website') {
1156 $fallback = self::SCHEMA_TYPE_MAP[$key];
1157 continue;
1158 }
1159 return self::SCHEMA_TYPE_MAP[$key];
1160 }
1161
1162 return $fallback;
1163 }
1164
1165 // -------------------------------------------------------------------------
1166 // Storage helpers
1167 // -------------------------------------------------------------------------
1168
1169 /**
1170 * Decoded `sq_options`. Squirrly stores it as a JSON string; older
1171 * versions used a serialized array.
1172 *
1173 * @return array
1174 */
1175 private function get_options(): array {
1176 if ($this->options_cache !== null) {
1177 return $this->options_cache;
1178 }
1179
1180 $raw = get_option('sq_options', []);
1181 if (is_string($raw)) {
1182 $decoded = json_decode($raw, true);
1183 if (!is_array($decoded)) {
1184 $decoded = is_serialized($raw) ? Safe_Unserializer::to_array($raw) : [];
1185 }
1186 $raw = $decoded;
1187 }
1188
1189 $this->options_cache = is_array($raw) ? $raw : [];
1190
1191 return $this->options_cache;
1192 }
1193
1194 /**
1195 * Whether a Squirrly table exists.
1196 *
1197 * @param string $suffix Table suffix without prefix
1198 * @return bool
1199 */
1200 private function table_exists(string $suffix = self::TABLE): bool {
1201 global $wpdb;
1202
1203 $table = $wpdb->prefix . $suffix;
1204
1205 return (bool) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
1206 }
1207
1208 /**
1209 * Total qss rows for this blog.
1210 *
1211 * @return int
1212 */
1213 private function count_rows(): int {
1214 global $wpdb;
1215
1216 $table = $wpdb->prefix . self::TABLE;
1217
1218 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
1219 return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE blog_id = %d", get_current_blog_id()));
1220 }
1221
1222 /**
1223 * Advanced Pack redirect rows that are URL redirects.
1224 *
1225 * @return int
1226 */
1227 private function count_redirects(): int {
1228 if (!$this->table_exists(self::REDIRECTS_TABLE)) {
1229 return 0;
1230 }
1231
1232 global $wpdb;
1233
1234 $table = $wpdb->prefix . self::REDIRECTS_TABLE;
1235
1236 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal.
1237 return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE action_type = 'url'");
1238 }
1239
1240 /**
1241 * Row counts per kind (post/term/user/home), from the `post` column only.
1242 *
1243 * @return array
1244 */
1245 private function get_kind_counts(): array {
1246 if ($this->kind_counts !== null) {
1247 return $this->kind_counts;
1248 }
1249
1250 $counts = ['post' => 0, 'term' => 0, 'user' => 0, 'home' => 0, 'other' => 0];
1251
1252 if ($this->table_exists()) {
1253 global $wpdb;
1254
1255 $table = $wpdb->prefix . self::TABLE;
1256 $rows = $wpdb->get_results(
1257 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
1258 $wpdb->prepare("SELECT post FROM {$table} WHERE blog_id = %d", get_current_blog_id()),
1259 ARRAY_A
1260 );
1261
1262 foreach ((array) $rows as $row) {
1263 $kind = $this->classify_row(['post' => $row['post'] ?? '', 'seo' => ''])['kind'];
1264 $counts[$kind]++;
1265 }
1266 }
1267
1268 $this->kind_counts = $counts;
1269
1270 return $counts;
1271 }
1272
1273 /**
1274 * One page of qss rows, in id order. Sets last_page_row_count so
1275 * export_chunk() paginates on rows fetched, not records emitted (a page
1276 * of term rows yields no post records but is not the end).
1277 *
1278 * @param int $page Page number (1-indexed)
1279 * @return array Rows
1280 */
1281 private function get_rows_page(int $page): array {
1282 if (!$this->table_exists()) {
1283 $this->last_page_row_count = 0;
1284 return [];
1285 }
1286
1287 global $wpdb;
1288
1289 $table = $wpdb->prefix . self::TABLE;
1290 $offset = ($page - 1) * $this->chunk_size;
1291
1292 $rows = $wpdb->get_results(
1293 $wpdb->prepare(
1294 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
1295 "SELECT id, URL, post, seo FROM {$table} WHERE blog_id = %d ORDER BY id ASC LIMIT %d OFFSET %d",
1296 get_current_blog_id(),
1297 $this->chunk_size,
1298 $offset
1299 ),
1300 ARRAY_A
1301 );
1302
1303 $this->last_page_row_count = is_array($rows) ? count($rows) : 0;
1304
1305 return is_array($rows) ? $rows : [];
1306 }
1307
1308 /**
1309 * The homepage row's seo array, when Squirrly has one.
1310 *
1311 * @return array
1312 */
1313 private function get_home_seo(): array {
1314 if (!$this->table_exists()) {
1315 return [];
1316 }
1317
1318 global $wpdb;
1319
1320 $table = $wpdb->prefix . self::TABLE;
1321 $rows = $wpdb->get_results(
1322 $wpdb->prepare(
1323 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
1324 "SELECT post, seo FROM {$table} WHERE blog_id = %d AND post LIKE %s LIMIT 5",
1325 get_current_blog_id(),
1326 '%' . $wpdb->esc_like('"home"') . '%'
1327 ),
1328 ARRAY_A
1329 );
1330
1331 foreach ((array) $rows as $row) {
1332 $parsed = $this->classify_row($row);
1333 if ($parsed['kind'] === 'home') {
1334 return $parsed['seo'];
1335 }
1336 }
1337
1338 return [];
1339 }
1340 }
1341