PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.1 2.14.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 All 56 releases
thinkrank / includes / seo / class-schema-management-system.php

class-schema-management-system.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.1, at includes/seo/class-schema-management-system.php

2,824 lines 116.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Schema Management System Class
4 *
5 * Streamlined schema markup generation and management system with structured
6 * data creation, validation, and automated deployment. Implements 2025 Schema.org
7 * standards with clean separation of concerns.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 use ThinkRank\Config\Schema_Settings_Config;
19
20 // Prevent direct access
21 if (!defined('ABSPATH')) {
22 exit;
23 }
24
25 /**
26 * Schema Management System Class
27 *
28 * Provides streamlined schema markup management with generation, validation,
29 * rich snippets optimization, and automated deployment. Focuses on core
30 * structured data creation with clean separation of concerns.
31 *
32 * @since 1.0.0
33 */
34 class Schema_Management_System extends Abstract_SEO_Manager {
35
36 /**
37 * Schema.org types with 2025 specifications
38 *
39 * @since 1.0.0
40 * @var array
41 */
42 private array $schema_types = [
43 'Article' => [
44 'name' => 'Article',
45 'description' => 'News articles, blog posts, and editorial content',
46 'required_properties' => ['headline', 'author', 'datePublished'],
47 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage'],
48 'rich_snippets' => ['article', 'news_article', 'blog_posting'],
49 'context_types' => ['post', 'page'],
50 'priority' => 'high'
51 ],
52 'TechnicalArticle' => [
53 'name' => 'TechnicalArticle',
54 'description' => 'Technical documentation and tutorials',
55 'required_properties' => ['headline', 'author', 'datePublished'],
56 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'dependencies', 'proficiencyLevel'],
57 'rich_snippets' => ['article', 'technical_article'],
58 'context_types' => ['post', 'page'],
59 'priority' => 'high'
60 ],
61 'NewsArticle' => [
62 'name' => 'NewsArticle',
63 'description' => 'News articles and press releases',
64 'required_properties' => ['headline', 'author', 'datePublished'],
65 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'dateline'],
66 'rich_snippets' => ['article', 'news_article'],
67 'context_types' => ['post', 'page'],
68 'priority' => 'high'
69 ],
70 'ScholarlyArticle' => [
71 'name' => 'ScholarlyArticle',
72 'description' => 'Academic and research articles',
73 'required_properties' => ['headline', 'author', 'datePublished'],
74 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'citation', 'abstract'],
75 'rich_snippets' => ['article', 'scholarly_article'],
76 'context_types' => ['post', 'page'],
77 'priority' => 'high'
78 ],
79 'Report' => [
80 'name' => 'Report',
81 'description' => 'Reports and analytical content',
82 'required_properties' => ['headline', 'author', 'datePublished'],
83 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage'],
84 'rich_snippets' => ['article', 'report'],
85 'context_types' => ['post', 'page'],
86 'priority' => 'medium'
87 ],
88 'Product' => [
89 'name' => 'Product',
90 'description' => 'Products for e-commerce and retail',
91 'required_properties' => ['name', 'description'],
92 'recommended_properties' => ['image', 'offers', 'brand', 'sku', 'gtin', 'review', 'aggregateRating'],
93 'rich_snippets' => ['product', 'offer', 'review'],
94 'context_types' => ['product', 'page', 'post'],
95 'priority' => 'critical'
96 ],
97 'LocalBusiness' => [
98 'name' => 'LocalBusiness',
99 'description' => 'Local businesses and service providers',
100 'required_properties' => ['name', 'address'],
101 'recommended_properties' => ['telephone', 'url', 'openingHours', 'geo', 'priceRange'],
102 'rich_snippets' => ['local_business', 'organization'],
103 'context_types' => ['site', 'page'],
104 'priority' => 'high'
105 ],
106 'Organization' => [
107 'name' => 'Organization',
108 'description' => 'Companies, corporations, and institutions',
109 'required_properties' => ['name', 'url'],
110 'recommended_properties' => ['logo', 'contactPoint', 'sameAs', 'address'],
111 'rich_snippets' => ['organization', 'corporation'],
112 'context_types' => ['site'],
113 'priority' => 'medium'
114 ],
115 'WebSite' => [
116 'name' => 'WebSite',
117 'description' => 'Website and web application information',
118 'required_properties' => ['name', 'url'],
119 'recommended_properties' => ['description', 'author', 'publisher', 'potentialAction'],
120 'rich_snippets' => ['website', 'sitelinks_searchbox'],
121 'context_types' => ['site'],
122 'priority' => 'high'
123 ],
124 'SoftwareApplication' => [
125 'name' => 'SoftwareApplication',
126 'description' => 'Software applications and web apps',
127 'required_properties' => ['name', 'applicationCategory'],
128 'recommended_properties' => ['description', 'url', 'offers', 'creator', 'features', 'aggregateRating'],
129 'rich_snippets' => ['software', 'app_rating', 'pricing'],
130 'context_types' => ['post', 'page'],
131 'priority' => 'high'
132 ],
133 'Person' => [
134 'name' => 'Person',
135 'description' => 'Individual people and authors',
136 'required_properties' => ['name'],
137 'recommended_properties' => ['url', 'image'],
138 'optional_properties' => ['jobTitle', 'worksFor', 'sameAs', 'description'],
139 'rich_snippets' => ['person', 'author'],
140 'context_types' => ['post', 'page'],
141 'priority' => 'medium'
142 ],
143
144 'HowTo' => [
145 'name' => 'HowTo',
146 'description' => 'Step-by-step instructions and tutorials',
147 'required_properties' => ['name', 'step'],
148 'recommended_properties' => ['image', 'totalTime', 'estimatedCost', 'tool', 'supply'],
149 'rich_snippets' => ['how_to', 'recipe'],
150 'context_types' => ['post', 'page'],
151 'priority' => 'medium'
152 ],
153 'Event' => [
154 'name' => 'Event',
155 'description' => 'Events, conferences, and gatherings',
156 'required_properties' => ['name', 'startDate', 'location'],
157 'recommended_properties' => ['endDate', 'description', 'image', 'offers', 'performer'],
158 'rich_snippets' => ['event', 'social_event'],
159 'context_types' => ['post', 'page'],
160 'priority' => 'medium'
161 ],
162 'VideoObject' => [
163 'name' => 'VideoObject',
164 'description' => 'Videos and embedded media content',
165 'required_properties' => ['name', 'description', 'thumbnailUrl', 'uploadDate'],
166 'recommended_properties' => ['contentUrl', 'embedUrl', 'duration'],
167 'rich_snippets' => ['video', 'video_carousel'],
168 'context_types' => ['post', 'page'],
169 'priority' => 'medium'
170 ],
171 // Offered by the metabox dropdown and registered in Schema_Factory, but
172 // absent here — generate_schema_markup() keys off this array, so a
173 // Review request was silently skipped (#462).
174 'Review' => [
175 'name' => 'Review',
176 'description' => 'Reviews and ratings of a product, service or place',
177 'required_properties' => ['itemReviewed', 'reviewRating', 'author'],
178 'recommended_properties' => ['reviewBody', 'datePublished', 'publisher'],
179 'rich_snippets' => ['review', 'review_snippet'],
180 'context_types' => ['post', 'page'],
181 'priority' => 'medium'
182 ],
183 'Recipe' => [
184 'name' => 'Recipe',
185 'description' => 'Cooking recipes and food preparation',
186 'required_properties' => ['name', 'image', 'author', 'datePublished', 'description', 'recipeIngredient', 'recipeInstructions'],
187 'recommended_properties' => ['cookTime', 'prepTime', 'totalTime', 'recipeYield', 'nutrition'],
188 'rich_snippets' => ['recipe', 'cooking'],
189 'context_types' => ['post', 'page'],
190 'priority' => 'medium'
191 ],
192 'BlogPosting' => [
193 'name' => 'BlogPosting',
194 'description' => 'Blog posts and personal articles',
195 'required_properties' => ['headline', 'author', 'datePublished'],
196 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'wordCount'],
197 'rich_snippets' => ['article', 'blog_posting'],
198 'context_types' => ['post'],
199 'priority' => 'high'
200 ],
201 'WebPage' => [
202 'name' => 'WebPage',
203 'description' => 'Individual web pages',
204 'required_properties' => ['name', 'url'],
205 // 'post' as well as 'page': the per-page selector reaches this for
206 // any post type, and a registry limited to 'page' silently produced
207 // nothing for the rest (#624).
208 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
209 'rich_snippets' => ['webpage', 'breadcrumb'],
210 'context_types' => ['page', 'post'],
211 'priority' => 'medium'
212 ],
213 'AboutPage' => [
214 'name' => 'AboutPage',
215 'description' => 'A page describing the organisation or person behind the site',
216 'required_properties' => ['name', 'url'],
217 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
218 'rich_snippets' => ['webpage', 'breadcrumb'],
219 'context_types' => ['page', 'post'],
220 'priority' => 'medium'
221 ],
222 'ContactPage' => [
223 'name' => 'ContactPage',
224 'description' => 'A page giving contact details',
225 'required_properties' => ['name', 'url'],
226 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
227 'rich_snippets' => ['webpage', 'breadcrumb'],
228 'context_types' => ['page', 'post'],
229 'priority' => 'medium'
230 ],
231 'ProfilePage' => [
232 'name' => 'ProfilePage',
233 'description' => 'A page about a single person or organisation',
234 'required_properties' => ['name', 'url'],
235 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
236 'rich_snippets' => ['webpage', 'breadcrumb'],
237 'context_types' => ['page', 'post'],
238 'priority' => 'medium'
239 ],
240 'FAQPage' => [
241 'name' => 'FAQPage',
242 'description' => 'Frequently Asked Questions pages',
243 'required_properties' => ['mainEntity'],
244 'recommended_properties' => ['about', 'author'],
245 'rich_snippets' => ['faq', 'question'],
246 'context_types' => ['page', 'post'],
247 'priority' => 'high'
248 ],
249
250 ];
251
252 /**
253 * Rich snippets configuration with Google guidelines
254 *
255 * @since 1.0.0
256 * @var array
257 */
258 private array $rich_snippets_config = [
259 'testing_tools' => [
260 'google_structured_data' => 'https://search.google.com/test/rich-results',
261 'schema_markup_validator' => 'https://validator.schema.org/',
262 'google_rich_results' => 'https://search.google.com/search-console/rich-results'
263 ],
264 'appearance_tracking' => [
265 'search_appearance' => ['title', 'description', 'image', 'rating', 'price'],
266 'rich_features' => ['breadcrumbs', 'sitelinks', 'reviews', 'faq', 'how_to'],
267 'performance_metrics' => ['click_through_rate', 'impressions', 'position']
268 ],
269 'optimization_guidelines' => [
270 'image_requirements' => [
271 'min_width' => 1200,
272 'min_height' => 675,
273 'aspect_ratio' => '16:9',
274 'formats' => ['jpg', 'png', 'webp']
275 ],
276 'content_requirements' => [
277 'min_description_length' => 50,
278 'max_description_length' => 300,
279 'required_fields_completion' => 80
280 ]
281 ]
282 ];
283
284 /**
285 * Schema validation rules and requirements
286 *
287 * @since 1.0.0
288 * @var array
289 */
290 private array $validation_rules = [
291 'required_context' => '@context',
292 'required_type' => '@type',
293 'url_validation' => [
294 'protocols' => ['http', 'https'],
295 'format_check' => true
296 ],
297 'date_validation' => [
298 'format' => 'ISO8601',
299 'timezone_aware' => true
300 ],
301 'image_validation' => [
302 'url_required' => true,
303 'dimensions_check' => true,
304 'format_validation' => true
305 ],
306 'text_validation' => [
307 'html_allowed' => false,
308 'length_limits' => true,
309 'encoding' => 'UTF-8'
310 ]
311 ];
312
313 /**
314 * Schema deployment configuration
315 *
316 * @since 1.0.0
317 * @var array
318 */
319 private array $deployment_config = [
320 'output_methods' => [
321 'json_ld' => [
322 'enabled' => true,
323 'priority' => 1,
324 'location' => 'head'
325 ]
326 ],
327 'caching' => [
328 'enabled' => true,
329 'duration' => 3600, // 1 hour
330 'invalidation_triggers' => ['content_update', 'settings_change']
331 ],
332 'conditional_loading' => [
333 'context_specific' => true,
334 'user_agent_detection' => false,
335 'performance_based' => true
336 ]
337 ];
338
339 /**
340 * Schema Builder instance for schema construction
341 *
342 * @var \ThinkRank\SEO\Schema_Builder|null
343 */
344 private ?\ThinkRank\SEO\Schema_Builder $schema_builder = null;
345
346 /**
347 * Schema Cache Manager instance for performance optimization
348 *
349 * @since 1.0.0
350 * @var Schema_Cache_Manager|null
351 */
352 private ?Schema_Cache_Manager $cache_manager = null;
353
354 /**
355 * Whether the foreign-settings listener has been registered this request.
356 *
357 * Static because `thinkrank_seo_settings_saved` is a global hook — one
358 * listener serves every instance. See the constructor for why (#463).
359 *
360 * @since 1.16.0
361 * @var bool
362 */
363 private static bool $foreign_settings_listener_registered = false;
364
365 /**
366 * Constructor
367 *
368 * @since 1.0.0
369 */
370 public function __construct() {
371 parent::__construct('schema_management_system');
372
373 // Load shared settings configuration
374 if (!class_exists('ThinkRank\\Config\\Schema_Settings_Config')) {
375 require_once THINKRANK_PLUGIN_DIR . 'includes/config/schema-settings-config.php';
376 }
377
378 // Initialize Schema Builder for schema construction
379 $this->initialize_schema_builder();
380
381 // Initialize Schema Cache Manager for performance optimization
382 $this->initialize_cache_manager();
383
384 // LocalBusiness and Organization both read Business Info, which Site
385 // Identity owns. Without this, editing an address or phone number never
386 // refreshed the deployed schema (#455).
387 //
388 // Registered at most once per request. WordPress keys callbacks by
389 // object hash, so binding $this here added a fresh listener for every
390 // instance — and this class is constructed from inside the very callback
391 // it registers, which doubled the listener count on every settings save
392 // (#463). The guard is static because the hook itself is global.
393 if (!self::$foreign_settings_listener_registered) {
394 self::$foreign_settings_listener_registered = true;
395 add_action('thinkrank_seo_settings_saved', [$this, 'refresh_schema_for_foreign_settings'], 10, 4);
396 }
397 }
398
399 /**
400 * Regenerate schema when another manager saves settings this schema reads.
401 *
402 * Site Identity owns the Business Info fields that feed LocalBusiness and
403 * the Organization address/contactPoint, so a save there has to refresh the
404 * deployed schema even though no schema setting changed.
405 *
406 * @since 2.0.2
407 *
408 * @param string $manager_type Settings category that was saved.
409 * @param array $settings Settings that were written.
410 * @param string $context_type Context type.
411 * @param int|null $context_id Context ID.
412 * @return void
413 */
414 public function refresh_schema_for_foreign_settings(
415 string $manager_type,
416 array $settings,
417 string $context_type,
418 ?int $context_id
419 ): void {
420 if ('site_identity' !== $manager_type) {
421 return;
422 }
423
424 $business_keys = [
425 'business_name', 'business_type', 'business_address', 'business_city',
426 'business_state', 'business_postal_code', 'business_country',
427 'business_phone', 'business_email', 'business_hours',
428 'business_latitude', 'business_longitude', 'business_price_range',
429 ];
430
431 // Which deployed types a Site Identity key can invalidate. The business
432 // block feeds LocalBusiness and Organization; alternate_name feeds the
433 // WebSite node, which had no entry here at all — so editing it left the
434 // deployed schema showing the previous value until something else
435 // happened to redeploy (#692).
436 $refresh_types = [];
437
438 if (!empty(array_intersect_key($settings, array_flip($business_keys)))) {
439 $refresh_types[] = 'LocalBusiness';
440 $refresh_types[] = 'Organization';
441 }
442
443 if (array_key_exists('alternate_name', $settings)) {
444 $refresh_types[] = 'WebSite';
445 }
446
447 if (empty($refresh_types)) {
448 return;
449 }
450
451 $schema_settings = $this->get_settings($context_type, $context_id);
452 if (empty($schema_settings['auto_deploy'])) {
453 return;
454 }
455
456 // Only refresh types that are actually deployed, so this never adds a
457 // type the admin did not enable.
458 $deployed = array_keys((array) $this->get_deployed_schemas($context_type, $context_id));
459 $affected = array_values(array_intersect($deployed, $refresh_types));
460
461 if (empty($affected)) {
462 return;
463 }
464
465 try {
466 $generation = $this->generate_schema_markup($context_type, $context_id, $affected);
467
468 // Deploy the types that validated, not all-or-nothing. Gating on
469 // deployment_ready meant one invalid type blocked every valid one
470 // in the same batch (#470).
471 $deployable = [];
472 foreach ($affected as $type) {
473 if (!empty($generation['generated_schemas'][$type])
474 && !empty($generation['validation_results'][$type]['is_valid'])
475 ) {
476 $deployable[$type] = $generation['generated_schemas'][$type];
477 }
478 }
479
480 if (!empty($deployable)) {
481 $this->deploy_schema_markup($context_type, $context_id, $deployable);
482 }
483 } catch (\Exception $e) {
484 if (defined('WP_DEBUG') && WP_DEBUG) {
485 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
486 error_log('ThinkRank: Business Info schema refresh failed: ' . $e->getMessage());
487 }
488 }
489 }
490
491 /**
492 * Initialize Schema Builder
493 *
494 * @return void
495 */
496 private function initialize_schema_builder(): void {
497 if (!class_exists('ThinkRank\\SEO\\Schema_Builder')) {
498 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-builder.php';
499 }
500
501 if (class_exists('ThinkRank\\SEO\\Schema_Builder')) {
502 $this->schema_builder = new \ThinkRank\SEO\Schema_Builder();
503 }
504 }
505
506 /**
507 * Initialize Schema Cache Manager
508 *
509 * @since 1.0.0
510 *
511 * @return void
512 */
513 private function initialize_cache_manager(): void {
514 if (!class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) {
515 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-cache-manager.php';
516 }
517
518 if (class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) {
519 // Honour the stored cache_duration setting. It is exposed in
520 // get_settings_schema() (min 300 / max 86400), validated, persisted
521 // and surfaced through both abilities — but the cache manager was
522 // always built from the hardcoded config value, so the setting had
523 // no effect (#473). Falls back to the config default.
524 $cache_duration = $this->deployment_config['caching']['duration'] ?? 3600;
525
526 $stored = $this->get_settings('site', null)['cache_duration'] ?? null;
527 if (is_numeric($stored) && (int) $stored > 0) {
528 $cache_duration = (int) $stored;
529 }
530
531 $this->cache_manager = new Schema_Cache_Manager($cache_duration);
532 }
533 }
534
535 /**
536 * Generate schema markup with comprehensive content analysis integration
537 *
538 * @since 1.0.0
539 *
540 * Generation is read-only by default. Persisting the result is opt-in via
541 * `$options['persist']`, because this method is also reached from the
542 * front-end read path (get_output_data()) and from GET routes — where a
543 * DELETE + INSERT would destroy the admin's deployed rows and publish
544 * types nobody deployed (#460).
545 *
546 * @param string $context_type Context type
547 * @param int|null $context_id Context ID
548 * @param array $schema_types Schema types to generate
549 * @param array $options Generation options. Pass `persist => true`
550 * from explicit write paths only.
551 * @return array Comprehensive schema generation results
552 */
553 public function generate_schema_markup(string $context_type, ?int $context_id, array $schema_types = [], array $options = []): array {
554 $generation = [
555 'context_type' => $context_type,
556 'context_id' => $context_id,
557 'generated_schemas' => [],
558 'validation_results' => [],
559 'rich_snippets_preview' => [],
560 'optimization_recommendations' => [],
561 'deployment_ready' => false,
562 'generation_timestamp' => current_time('mysql')
563 ];
564
565 // Auto-detect schema types if not provided
566 if (empty($schema_types)) {
567 $schema_types = $this->auto_detect_schema_types($context_type, $context_id);
568 }
569
570 // Apply content schema settings from options
571 $schema_types = $this->apply_content_schema_settings_from_options($schema_types, $options, $context_type);
572
573 // Simplified: Content analysis and optimization moved to separate services
574 // Schema generation focuses on core structured data creation
575
576 // Generate schema for each type using Schema_Builder directly
577 foreach ($schema_types as $schema_type) {
578 if (isset($this->schema_types[$schema_type])) {
579 // Prepare content data for Schema_Builder
580 $content_data = $this->prepare_content_data_for_generator(
581 $context_type,
582 $context_id,
583 [], // Simplified: content analysis handled by separate services
584 [], // Simplified: optimization data handled by separate services
585 $options
586 );
587
588 // Simplified: Knowledge graph enhancements moved to separate service
589
590 // Generate schema using Schema_Builder directly
591 $schema_data = $this->schema_builder->build_schema(
592 $schema_type,
593 $content_data,
594 $context_type
595 );
596
597 // Apply rich snippets optimization if enabled
598 if ($options['rich_snippets_optimization'] ?? true) {
599 $schema_data = $this->apply_rich_snippets_optimization($schema_data, $schema_type);
600 }
601
602 $generation['generated_schemas'][$schema_type] = $schema_data;
603
604 // Validate generated schema using proper Schema_Validator
605 $validation = $this->validate_schema_markup($schema_data, $schema_type);
606 $generation['validation_results'][$schema_type] = $validation;
607
608 // Generate rich snippets preview
609 $preview = $this->generate_rich_snippets_preview($schema_data, $schema_type);
610 $generation['rich_snippets_preview'][$schema_type] = $preview;
611 }
612 }
613
614 // Generate optimization recommendations
615 $generation['optimization_recommendations'] = $this->generate_schema_optimization_recommendations(
616 $generation['generated_schemas'],
617 $generation['validation_results']
618 );
619
620 // Check deployment readiness
621 $generation['deployment_ready'] = $this->check_deployment_readiness($generation['validation_results']);
622
623 // Persistence belongs to deployment, not generation. Every write path
624 // (refresh_schema_for_foreign_settings(), auto_deploy_schema_on_settings_change(),
625 // the deploy route) calls deploy_schema_markup() straight after generating,
626 // so nothing needs to opt in today — the flag exists to keep this an
627 // explicit decision rather than an accident.
628 if (!empty($options['persist'])) {
629 $this->store_schema_data($context_type, $context_id, $generation);
630 }
631
632 return $generation;
633 }
634
635 /**
636 * Validate schema markup with comprehensive testing
637 *
638 * @since 1.0.0
639 *
640 * @param array $schema_data Schema data to validate
641 * @param string $schema_type Schema type
642 * @param array $options Validation options
643 * @return array Comprehensive validation results
644 */
645 public function validate_schema_markup(array $schema_data, string $schema_type, array $options = []): array {
646 $validation = [
647 'schema_type' => $schema_type,
648 'is_valid' => false,
649 'validation_score' => 0,
650 'required_properties_check' => [],
651 'recommended_properties_check' => [],
652 'structural_validation' => [],
653 'google_guidelines_compliance' => [],
654 'rich_snippets_eligibility' => [],
655 'errors' => [],
656 'warnings' => [],
657 'suggestions' => [],
658 'validation_timestamp' => current_time('mysql')
659 ];
660
661 // Use Schema_Validator for validation
662 if (!class_exists('ThinkRank\\SEO\\Schema_Validator')) {
663 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-validator.php';
664 }
665
666 $schema_validator = new \ThinkRank\SEO\Schema_Validator();
667 $validator_validation = $schema_validator->validate_schema($schema_data);
668
669 // Map Schema_Validator validation to our format
670 if ($validator_validation) {
671 $validation['is_valid'] = $validator_validation['valid'] ?? false;
672 $validation['validation_score'] = $validator_validation['score'] ?? 0;
673 $validation['errors'] = $validator_validation['errors'] ?? [];
674 $validation['warnings'] = $validator_validation['warnings'] ?? [];
675 $validation['suggestions'] = $validator_validation['suggestions'] ?? [];
676
677 // Set basic validation checks
678 $validation['required_properties_check'] = ['status' => 'checked'];
679 $validation['recommended_properties_check'] = ['status' => 'checked'];
680 $validation['structural_validation'] = ['valid_structure' => $validation['is_valid']];
681 $validation['google_guidelines_compliance'] = ['compliant' => $validation['is_valid']];
682 $validation['rich_snippets_eligibility'] = ['eligible' => $validation['is_valid']];
683 } else {
684 // Fallback validation
685 $validation['is_valid'] = !empty($schema_data['@type']);
686 $validation['validation_score'] = $validation['is_valid'] ? 85 : 0;
687 }
688
689 return $validation;
690 }
691
692 /**
693 * Optimize rich snippets with preview and testing capabilities
694 *
695 * @since 1.0.0
696 *
697 * @param array $schema_data Schema data to optimize
698 * @param string $schema_type Schema type
699 * @param array $options Optimization options
700 * @return array Rich snippets optimization results
701 */
702 public function optimize_rich_snippets(array $schema_data, string $schema_type, array $options = []): array {
703 $optimization = [
704 'schema_type' => $schema_type,
705 'original_schema' => $schema_data,
706 'optimized_schema' => [],
707 'rich_snippets_preview' => [],
708 'optimization_score' => 0,
709 'appearance_probability' => 0,
710 'optimization_changes' => [],
711 'testing_results' => [],
712 'recommendations' => [],
713 'optimization_timestamp' => current_time('mysql')
714 ];
715
716 // Rich snippets optimization is not yet fully implemented
717 // Return the original schema with basic optimization info
718 $optimization['optimized_schema'] = $schema_data;
719
720 // Generate rich snippets preview
721 $optimization['rich_snippets_preview'] = $this->generate_rich_snippets_preview($schema_data, $schema_type);
722
723 // Basic optimization metrics
724 $optimization['optimization_score'] = 85; // Default score
725 $optimization['appearance_probability'] = 75; // Default probability
726 $optimization['optimization_changes'] = [];
727 $optimization['testing_results'] = ['status' => 'not_implemented'];
728 $optimization['recommendations'] = ['message' => 'Rich snippets optimization is not yet fully implemented'];
729
730 return $optimization;
731 }
732
733 /**
734 * Deployed types an authoritative deploy takes off the page.
735 *
736 * Everything deployed that the payload left out, except the types the
737 * caller sent but could not deploy: those failed validation, they were not
738 * removed by the user, so their live copy stays. Retiring them took the
739 * site's Organization down behind a success toast (#949).
740 *
741 * @since 2.14.1
742 *
743 * @param string[] $deployed Types deployed for the context now.
744 * @param string[] $payload Types in this deploy.
745 * @param string[] $retain_types Types sent but skipped by validation.
746 * @return string[] Types to retire.
747 */
748 public static function types_to_retire(array $deployed, array $payload, array $retain_types = []): array {
749 return array_values(array_diff($deployed, $payload, $retain_types));
750 }
751
752 /**
753 * Deploy schema markup with automated implementation
754 *
755 * @since 1.0.0
756 *
757 * @param string $context_type Context type
758 * @param int|null $context_id Context ID
759 * @param array $schema_data Schema data to deploy
760 * @param array $options Deployment options
761 * @return array Schema deployment results
762 */
763 public function deploy_schema_markup(string $context_type, ?int $context_id, array $schema_data, array $options = []): array {
764 $deployment = [
765 'context_type' => $context_type,
766 'context_id' => $context_id,
767 'deployment_method' => 'json_ld', // Always JSON-LD (only supported method)
768 'deployment_status' => 'pending',
769 'deployed_schemas' => [],
770 'deployment_location' => 'head',
771 'cache_status' => [],
772 'validation_post_deployment' => [],
773 'deployment_timestamp' => current_time('mysql')
774 ];
775
776 // Determine deployment method
777 $deployment['deployment_method'] = $this->determine_deployment_method($options);
778
779 // When the caller owns the whole context — the user pressing Deploy, where
780 // the payload is exactly what the preview showed — anything not in that
781 // payload should come off the page (#464). Incremental callers such as
782 // auto_deploy_schema_on_settings_change() pass only the types they
783 // regenerated, so they must NOT retire the rest.
784 if (!empty($options['authoritative'])) {
785 $deployment['retired_schemas'] = $this->retire_schema_types(
786 $context_type,
787 $context_id,
788 self::types_to_retire(
789 array_keys($this->get_deployed_schemas($context_type, $context_id)),
790 array_keys($schema_data),
791 (array) ($options['retain_types'] ?? [])
792 )
793 );
794 }
795
796 // Deploy each schema
797 foreach ($schema_data as $schema_type => $schema) {
798 $deploy_result = $this->deploy_single_schema($schema, $schema_type, $deployment['deployment_method'], $context_type, $context_id);
799 $deployment['deployed_schemas'][$schema_type] = $deploy_result;
800 }
801
802 // Clean up duplicate schemas
803 $this->cleanup_duplicate_schemas($context_type, $context_id);
804
805 // CACHE INVALIDATION: Clear cache after successful deployment
806 $cache_invalidated = false;
807 if ($this->cache_manager && !empty($deployment['deployed_schemas'])) {
808 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
809 $cache_invalidated = true;
810 }
811
812 $deployment['cache_status'] = $cache_invalidated
813 ? ['cache_updated' => true, 'message' => 'Schema cache invalidated']
814 : ['cache_updated' => false, 'message' => 'No schema cache to invalidate'];
815
816 // Post-deployment verification: read back through the same accessor the
817 // front end uses, so a row that was written but is not retrievable (wrong
818 // context, inactive, stale cache) is reported as a failure instead of
819 // being assumed successful.
820 $deployment['validation_post_deployment'] = $this->verify_deployment(
821 $context_type,
822 $context_id,
823 array_keys($deployment['deployed_schemas'])
824 );
825
826 $writes_ok = !empty($deployment['deployed_schemas']);
827 foreach ($deployment['deployed_schemas'] as $deploy_result) {
828 if (empty($deploy_result['deployed'])) {
829 $writes_ok = false;
830 break;
831 }
832 }
833
834 $deployment['deployment_status'] =
835 ($writes_ok && !empty($deployment['validation_post_deployment']['validation_passed']))
836 ? 'success'
837 : 'failed';
838
839 return $deployment;
840 }
841
842 /**
843 * Verify deployed schema is retrievable after a deploy.
844 *
845 * Reads back through get_deployed_schemas() — the same accessor
846 * Frontend\SEO_Manager::output_site_schema_markup() uses to emit schema — so
847 * the check reflects what will actually reach the page rather than only that
848 * an INSERT returned without error.
849 *
850 * @since 1.32.0
851 *
852 * @param string $context_type Context type
853 * @param int|null $context_id Context ID
854 * @param array $expected_types Schema types that were just deployed
855 * @return array Validation result
856 */
857 private function verify_deployment(string $context_type, ?int $context_id, array $expected_types): array {
858 if (empty($expected_types)) {
859 return [
860 'validation_passed' => false,
861 'message' => 'No schema was deployed',
862 'missing_types' => []
863 ];
864 }
865
866 $retrieved = $this->get_deployed_schemas($context_type, $context_id);
867 $missing = array_values(array_diff($expected_types, array_keys($retrieved)));
868
869 if (!empty($missing)) {
870 return [
871 'validation_passed' => false,
872 'message' => sprintf(
873 /* translators: %s: comma-separated list of schema types */
874 __('Deployed schema could not be read back: %s', 'thinkrank'),
875 implode(', ', $missing)
876 ),
877 'missing_types' => $missing
878 ];
879 }
880
881 return [
882 'validation_passed' => true,
883 'message' => __('Schema deployed and read back from storage', 'thinkrank'),
884 'missing_types' => []
885 ];
886 }
887
888 /**
889 * Track schema performance and rich snippet appearances
890 *
891 * @since 1.0.0
892 *
893 * @param string $context_type Context type
894 * @param int|null $context_id Context ID
895 * @param array $options Tracking options
896 * @return array Schema performance tracking results
897 */
898 public function track_schema_performance(string $context_type, ?int $context_id, array $options = []): array {
899 // Performance tracking is not yet implemented
900 // This method returns empty data structure for API compatibility
901 return [
902 'context_type' => $context_type,
903 'context_id' => $context_id,
904 'rich_snippets_appearances' => [],
905 'search_performance' => [],
906 'click_through_rates' => [],
907 'schema_errors' => [],
908 'performance_trends' => [],
909 'optimization_impact' => [],
910 'tracking_timestamp' => current_time('mysql'),
911 'tracking_enabled' => false,
912 'message' => 'Performance tracking feature is not yet implemented'
913 ];
914 }
915
916 /**
917 * Validate SEO settings (implements interface)
918 *
919 * @since 1.0.0
920 *
921 * @param array $settings Settings array to validate
922 * @return array Validation results
923 */
924 public function validate_settings(array $settings): array {
925 $validation = [
926 'valid' => true,
927 'errors' => [],
928 'warnings' => [],
929 'suggestions' => [],
930 'score' => 100
931 ];
932
933 // Validate schema types configuration
934 if (isset($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
935 foreach ($settings['enabled_schema_types'] as $schema_type) {
936 if (!isset($this->schema_types[$schema_type])) {
937 $validation['errors'][] = "Invalid schema type: {$schema_type}";
938 $validation['valid'] = false;
939 }
940 }
941 }
942
943 // Note: Only JSON-LD deployment method is supported (no validation needed since it's hardcoded)
944
945 // Validate auto-generation settings
946 if (isset($settings['auto_generate_schema']) && !is_bool($settings['auto_generate_schema'])) {
947 $validation['errors'][] = 'Auto-generate schema setting must be boolean';
948 $validation['valid'] = false;
949 }
950
951 // Validate validation requirements
952 if (isset($settings['validation_level'])) {
953 $valid_levels = ['strict', 'moderate', 'lenient'];
954 if (!in_array($settings['validation_level'], $valid_levels, true)) {
955 $validation['errors'][] = 'Invalid validation level specified';
956 $validation['valid'] = false;
957 }
958 }
959
960 // Validate rich snippets optimization
961 if (isset($settings['rich_snippets_optimization']) && !is_bool($settings['rich_snippets_optimization'])) {
962 $validation['errors'][] = 'Rich snippets optimization setting must be boolean';
963 $validation['valid'] = false;
964 }
965
966 // Validate performance tracking
967 if (isset($settings['performance_tracking']) && !is_bool($settings['performance_tracking'])) {
968 $validation['errors'][] = 'Performance tracking setting must be boolean';
969 $validation['valid'] = false;
970 }
971
972 // Validate cache settings
973 if (isset($settings['cache_duration'])) {
974 if (!is_numeric($settings['cache_duration']) || $settings['cache_duration'] < 0) {
975 $validation['errors'][] = 'Cache duration must be a positive number';
976 $validation['valid'] = false;
977 }
978 }
979
980 // Calculate validation score
981 $validation['score'] = $this->calculate_validation_score($validation);
982
983 return $validation;
984 }
985
986 /**
987 * Get output data for frontend rendering (implements interface)
988 *
989 * @since 1.0.0
990 *
991 * @param string $context_type The context type
992 * @param int|null $context_id Optional. Context ID
993 * @return array Output data ready for frontend rendering
994 */
995 public function get_output_data(string $context_type, ?int $context_id): array {
996 $settings = $this->get_settings($context_type, $context_id);
997
998 $output = [
999 'schema_dashboard' => [],
1000 'generated_schemas' => [],
1001 'validation_results' => [],
1002 'rich_snippets_preview' => [],
1003 'performance_data' => [],
1004 'recommendations' => [],
1005 // Report the real setting. Hardcoding true here told every consumer
1006 // the feature was on even when the master switch was off (#461).
1007 'enabled' => (bool) ($settings['enabled'] ?? true)
1008 ];
1009
1010 // Get enabled schema types
1011 $enabled_types = $settings['enabled_schema_types'] ?? [];
1012
1013 // Auto-generate schema types if enabled and no manual types specified
1014 if (empty($enabled_types) && ($settings['auto_generate_schema'] ?? true)) {
1015 $enabled_types = $this->auto_detect_schema_types($context_type, $context_id);
1016 }
1017
1018 // Add content-specific schema types based on settings
1019 $enabled_types = $this->apply_content_schema_settings($enabled_types, $settings, $context_type);
1020
1021 if (!empty($enabled_types)) {
1022 // Generate schema markup with enhanced options
1023 $generation_options = [
1024 'knowledge_graph' => $settings['knowledge_graph'] ?? true,
1025 'rich_snippets_optimization' => $settings['rich_snippets_optimization'] ?? true,
1026 'validation_level' => $settings['validation_level'] ?? 'moderate'
1027 ];
1028
1029 $generation_results = $this->generate_schema_markup($context_type, $context_id, $enabled_types, $generation_options);
1030
1031 // Populate output data
1032 $output['generated_schemas'] = $generation_results['generated_schemas'] ?? [];
1033 $output['validation_results'] = $generation_results['validation_results'] ?? [];
1034 $output['rich_snippets_preview'] = $generation_results['rich_snippets_preview'] ?? [];
1035 $output['recommendations'] = $generation_results['optimization_recommendations'] ?? [];
1036
1037 // Get schema dashboard data
1038 $output['schema_dashboard'] = [
1039 'total_schemas' => count($generation_results['generated_schemas'] ?? []),
1040 'valid_schemas' => count(array_filter($generation_results['validation_results'] ?? [], function($v) { return $v['is_valid'] ?? false; })),
1041 'deployment_ready' => $generation_results['deployment_ready'] ?? false,
1042 'last_generated' => current_time('mysql')
1043 ];
1044
1045 // Get performance data if tracking enabled
1046 if ($settings['performance_tracking'] ?? false) {
1047 $output['performance_data'] = $this->track_schema_performance($context_type, $context_id);
1048 }
1049 }
1050
1051 return $output;
1052 }
1053
1054 // Removed knowledge graph enhancements - moved to separate service
1055 // Social links and enhanced data handled by Schema_Builder directly
1056
1057 /**
1058 * Apply rich snippets optimization to schema data
1059 *
1060 * @since 1.0.0
1061 *
1062 * @param array $schema_data Schema data
1063 * @param string $schema_type Schema type
1064 * @return array Optimized schema data
1065 */
1066 private function apply_rich_snippets_optimization(array $schema_data, string $schema_type): array {
1067 switch ($schema_type) {
1068 case 'Article':
1069 case 'BlogPosting':
1070 // Rich snippets optimization for articles
1071 // Note: Image is optional - only include if user provides one
1072 // No default image fallback to avoid non-existent image URLs
1073
1074 // Optimize headline length for rich snippets
1075 if (!empty($schema_data['headline']) && strlen($schema_data['headline']) > 110) {
1076 $schema_data['headline'] = substr($schema_data['headline'], 0, 107) . '...';
1077 }
1078 break;
1079
1080 case 'Organization':
1081 // Ensure logo for rich snippets
1082 if (empty($schema_data['logo'])) {
1083 $schema_data['logo'] = $this->get_default_organization_logo();
1084 }
1085 break;
1086
1087 case 'Product':
1088 // Ensure required properties for product rich snippets
1089 if (empty($schema_data['offers'])) {
1090 $schema_data['offers'] = [
1091 '@type' => 'Offer',
1092 'availability' => 'https://schema.org/InStock',
1093 'priceCurrency' => 'USD'
1094 ];
1095 }
1096 break;
1097 }
1098
1099 return $schema_data;
1100 }
1101
1102 /**
1103 * Apply content schema settings from options to enabled types
1104 *
1105 * @since 1.0.0
1106 *
1107 * @param array $enabled_types Current enabled types
1108 * @param array $options Generation options
1109 * @param string $context_type Context type
1110 * @return array Enhanced enabled types
1111 */
1112 private function apply_content_schema_settings_from_options(array $enabled_types, array $options, string $context_type): array {
1113 // For site context: only apply site-level schema settings
1114 if ($context_type === 'site') {
1115 // Add local business schema if enabled
1116 if ($options['enable_local_business'] ?? false) {
1117 if (!in_array('LocalBusiness', $enabled_types, true)) {
1118 $enabled_types[] = 'LocalBusiness';
1119 }
1120 }
1121
1122 return $enabled_types;
1123 }
1124
1125 // For post/page context: apply all schema settings (metabox functionality)
1126
1127 // Add article schema if enabled and context is appropriate
1128 if ($options['enable_article_schema'] ?? false) {
1129 if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) {
1130 $enabled_types[] = 'Article';
1131 }
1132 }
1133
1134 // Add FAQ schema if enabled
1135 if ($options['enable_faq_schema'] ?? false) {
1136 if (!in_array('FAQPage', $enabled_types, true)) {
1137 $enabled_types[] = 'FAQPage';
1138 }
1139 }
1140
1141 // Add How-To schema if enabled
1142 if ($options['enable_howto_schema'] ?? false) {
1143 if (!in_array('HowTo', $enabled_types, true)) {
1144 $enabled_types[] = 'HowTo';
1145 }
1146 }
1147
1148 // Add product schema if enabled and context is appropriate
1149 if ($options['enable_product_schema'] ?? false) {
1150 if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) {
1151 $enabled_types[] = 'Product';
1152 }
1153 }
1154
1155 // Add local business schema if enabled
1156 if ($options['enable_local_business'] ?? false) {
1157 if (!in_array('LocalBusiness', $enabled_types, true)) {
1158 $enabled_types[] = 'LocalBusiness';
1159 }
1160 }
1161
1162 return $enabled_types;
1163 }
1164
1165 /**
1166 * Apply content schema settings to enabled types
1167 *
1168 * @since 1.0.0
1169 *
1170 * @param array $enabled_types Current enabled types
1171 * @param array $settings Schema settings
1172 * @param string $context_type Context type
1173 * @return array Enhanced enabled types
1174 */
1175 private function apply_content_schema_settings(array $enabled_types, array $settings, string $context_type): array {
1176 // For site context: only apply site-level schema settings
1177 if ($context_type === 'site') {
1178 // Add local business schema if enabled
1179 if ($settings['enable_local_business'] ?? false) {
1180 if (!in_array('LocalBusiness', $enabled_types, true)) {
1181 $enabled_types[] = 'LocalBusiness';
1182 }
1183 }
1184
1185 // Add breadcrumbs schema if enabled (site-wide feature)
1186 if ($settings['enable_breadcrumbs_schema'] ?? false) {
1187 if (!in_array('BreadcrumbList', $enabled_types, true)) {
1188 $enabled_types[] = 'BreadcrumbList';
1189 }
1190 }
1191
1192 return $enabled_types;
1193 }
1194
1195 // For post/page context: apply all schema settings (metabox functionality)
1196
1197 // Add article schema if enabled and context is appropriate
1198 if ($settings['enable_article_schema'] ?? false) {
1199 if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) {
1200 $enabled_types[] = 'Article';
1201 }
1202 }
1203
1204 // Add FAQ schema if enabled
1205 if ($settings['enable_faq_schema'] ?? false) {
1206 if (!in_array('FAQPage', $enabled_types, true)) {
1207 $enabled_types[] = 'FAQPage';
1208 }
1209 }
1210
1211 // Add How-To schema if enabled
1212 if ($settings['enable_howto_schema'] ?? false) {
1213 if (!in_array('HowTo', $enabled_types, true)) {
1214 $enabled_types[] = 'HowTo';
1215 }
1216 }
1217
1218 // Add product schema if enabled and context is appropriate
1219 if ($settings['enable_product_schema'] ?? false) {
1220 if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) {
1221 $enabled_types[] = 'Product';
1222 }
1223 }
1224
1225 // Add local business schema if enabled
1226 if ($settings['enable_local_business'] ?? false) {
1227 if (!in_array('LocalBusiness', $enabled_types, true)) {
1228 $enabled_types[] = 'LocalBusiness';
1229 }
1230 }
1231
1232 return $enabled_types;
1233 }
1234
1235 /**
1236 * Get default organization logo for rich snippets
1237 *
1238 * @since 1.0.0
1239 *
1240 * @return string Default logo URL
1241 */
1242 private function get_default_organization_logo(): string {
1243 // Try to get custom logo
1244 $custom_logo_id = get_theme_mod('custom_logo');
1245 if ($custom_logo_id) {
1246 $logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
1247 if ($logo_url) {
1248 return $logo_url;
1249 }
1250 }
1251
1252 // Fallback to site icon or default
1253 $site_icon_url = get_site_icon_url();
1254 if ($site_icon_url) {
1255 return $site_icon_url;
1256 }
1257
1258 // Final fallback
1259 return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg');
1260 }
1261
1262 /**
1263 * Schema keys outside the shared config defaults.
1264 *
1265 * @since 2.0.1
1266 *
1267 * @return string[]
1268 */
1269 protected function additional_setting_keys(): array {
1270 return [
1271 'enable_article_schema', 'enable_product_schema',
1272 'enable_faq_schema', 'enable_howto_schema',
1273 ];
1274 }
1275
1276 /**
1277 * Per-entity schema fields are an open set.
1278 *
1279 * Each schema type the UI can edit contributes its own field family —
1280 * organization_*, person_*, website_*, business_*, software_*, howto_* —
1281 * and a new type adds another. The families this manager owns are matched
1282 * rather than enumerated, so adding a form does not silently start
1283 * dropping its fields (#452).
1284 *
1285 * @since 2.0.1
1286 *
1287 * @return string[]
1288 */
1289 protected function dynamic_setting_key_patterns(): array {
1290 return [
1291 '/^organization_[a-z0-9_]+$/',
1292 '/^person_[a-z0-9_]+$/',
1293 '/^website_[a-z0-9_]+$/',
1294 '/^business_[a-z0-9_]+$/',
1295 '/^software_[a-z0-9_]+$/',
1296 '/^howto_[a-z0-9_]+$/',
1297 '/^product_[a-z0-9_]+$/',
1298 ];
1299 }
1300
1301 /**
1302 * Get default settings for a context type (implements interface)
1303 *
1304 * @since 1.0.0
1305 *
1306 * @param string $context_type The context type to get defaults for
1307 * @return array Default settings array
1308 */
1309 public function get_default_settings(string $context_type): array {
1310 return Schema_Settings_Config::get_default_settings($context_type);
1311 }
1312
1313 /**
1314 * Get settings schema definition (implements interface)
1315 *
1316 * @since 1.0.0
1317 *
1318 * @param string $context_type The context type to get schema for
1319 * @return array Settings schema definition
1320 */
1321 public function get_settings_schema(string $context_type): array {
1322 return Schema_Settings_Config::get_settings_schema($context_type);
1323 }
1324
1325 /**
1326 * Save SEO settings with cache invalidation and auto-deployment
1327 *
1328 * Overrides parent method to add schema cache invalidation when settings change.
1329 * This ensures cached schema data is refreshed when configuration changes.
1330 * Also triggers auto-deployment of schema when enabled.
1331 *
1332 * @since 1.0.0
1333 *
1334 * @param string $context_type The context type
1335 * @param int|null $context_id Optional. Context ID
1336 * @param array $settings Settings array to save
1337 * @return bool True on success, false on failure
1338 */
1339 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
1340 // Call parent method to save settings
1341 $success = parent::save_settings($context_type, $context_id, $settings);
1342
1343 // CACHE INVALIDATION: Clear all schema cache when settings change
1344 if ($success && $this->cache_manager) {
1345 $this->cache_manager->invalidate_all_cache();
1346 }
1347
1348 // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
1349 if ($success && self::should_auto_deploy($settings, $this->get_settings($context_type, $context_id))) {
1350 $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1351 }
1352
1353 return $success;
1354 }
1355
1356
1357 /**
1358 * Whether a save should redeploy the schema it changed.
1359 *
1360 * `auto_deploy` is a stored setting (on by default), not something a save
1361 * restates. Reading it off the incoming patch meant a partial save — which
1362 * is what the admin screen sends, one field at a time — skipped
1363 * auto-deploy on a site that had it switched on, and the deployed snapshot
1364 * the front end serves kept the name, logo and sameAs it was deployed
1365 * with, however often the user saved (#904, the gate #12 left in place).
1366 *
1367 * A patch that does carry the key still wins, so a caller can deploy or
1368 * hold deliberately.
1369 *
1370 * @param array $patch Settings being saved
1371 * @param array $stored Settings as stored, after the save
1372 * @return bool
1373 */
1374 public static function should_auto_deploy(array $patch, array $stored): bool {
1375 if (array_key_exists('auto_deploy', $patch)) {
1376 return !empty($patch['auto_deploy']);
1377 }
1378
1379 return !empty($stored['auto_deploy']);
1380 }
1381
1382 /**
1383 * Auto-deploy schema when settings change
1384 *
1385 * Automatically regenerates and deploys schema markup when organization or other
1386 * schema settings are modified, ensuring the frontend output stays in sync.
1387 *
1388 * @since 1.0.0
1389 *
1390 * @param string $context_type Context type
1391 * @param int|null $context_id Context ID
1392 * @param array $settings Updated settings
1393 * @return void
1394 */
1395 private function auto_deploy_schema_on_settings_change(string $context_type, ?int $context_id, array $settings): void {
1396 // Determine which schema types need to be regenerated based on changed settings
1397 $schema_types_to_regenerate = [];
1398
1399 // Organization schema - regenerate if organization settings changed
1400 if ($this->has_organization_settings_changed($settings)) {
1401 $schema_types_to_regenerate[] = 'Organization';
1402 }
1403
1404 // Website schema - regenerate if website settings changed. The type is
1405 // registered as 'WebSite' (capital S) in Schema_Factory / $schema_types;
1406 // using 'Website' here made generate_schema_markup() silently skip it.
1407 if ($this->has_website_settings_changed($settings)) {
1408 $schema_types_to_regenerate[] = 'WebSite';
1409 }
1410
1411 // LocalBusiness schema - regenerate if business settings changed
1412 if ($this->has_business_settings_changed($settings)) {
1413 $schema_types_to_regenerate[] = 'LocalBusiness';
1414 }
1415
1416 // Person schema - regenerate if person settings changed
1417 if ($this->has_person_settings_changed($settings)) {
1418 $schema_types_to_regenerate[] = 'Person';
1419 }
1420
1421 // Honour the user's Schema Types selection. Without this the payload
1422 // shape alone decided what shipped, so every save deployed all four
1423 // types — including ones the user had explicitly deselected (#461).
1424 // An empty selection means "auto", so only filter when one is set.
1425 $enabled_types = $settings['enabled_schema_types'] ?? $this->get_settings($context_type, $context_id)['enabled_schema_types'] ?? [];
1426
1427 if (!empty($enabled_types) && is_array($enabled_types)) {
1428 $schema_types_to_regenerate = array_values(
1429 array_intersect($schema_types_to_regenerate, $enabled_types)
1430 );
1431 }
1432
1433 // Types that were deployed but are no longer wanted must come back off
1434 // the page — deployment used to be additive-only (#464).
1435 $this->retire_unselected_schema_types($context_type, $context_id, $enabled_types);
1436
1437 // If no schema types need regeneration, return early
1438 if (empty($schema_types_to_regenerate)) {
1439 return;
1440 }
1441
1442 // Generate every affected type in ONE call. Generating them one at a
1443 // time re-entered store_schema_data() per type, and each pass replaced
1444 // the rows written by the previous one, so only the last type survived
1445 // (#454). One batch also means one delete and one cache flush.
1446 try {
1447 $generation_result = $this->generate_schema_markup(
1448 $context_type,
1449 $context_id,
1450 $schema_types_to_regenerate
1451 );
1452
1453 $deployable = [];
1454 foreach ($schema_types_to_regenerate as $schema_type) {
1455 // Only deploy what validated — see #470.
1456 if (!empty($generation_result['generated_schemas'][$schema_type])
1457 && !empty($generation_result['validation_results'][$schema_type]['is_valid'])
1458 ) {
1459 $deployable[$schema_type] = $generation_result['generated_schemas'][$schema_type];
1460 }
1461 }
1462
1463 if (!empty($deployable)) {
1464 $this->deploy_schema_markup($context_type, $context_id, $deployable);
1465 }
1466 } catch (\Exception $e) {
1467 // Log error but don't fail the settings save
1468 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1469 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1470 error_log('ThinkRank: Auto-deploy failed for ' . implode(', ', $schema_types_to_regenerate) . ': ' . $e->getMessage());
1471 }
1472 }
1473 }
1474
1475 /**
1476 * Check if organization settings have changed
1477 *
1478 * @since 1.0.0
1479 *
1480 * @param array $settings Updated settings
1481 * @return bool True if organization settings changed
1482 */
1483 private function has_organization_settings_changed(array $settings): bool {
1484 $org_keys = [
1485 'organization_name', 'organization_type', 'organization_logo', 'organization_url',
1486 'organization_description', 'organization_social_facebook', 'organization_social_twitter',
1487 'organization_social_linkedin', 'organization_social_instagram', 'organization_social_youtube',
1488 'organization_social_pinterest', 'organization_social_whatsapp', 'organization_social_telegram',
1489 'organization_contact_type', 'organization_contact_phone', 'organization_contact_email',
1490 'organization_contact_hours'
1491 ];
1492
1493 foreach ($org_keys as $key) {
1494 if (isset($settings[$key])) {
1495 return true;
1496 }
1497 }
1498
1499 return false;
1500 }
1501
1502 /**
1503 * Check if website settings have changed
1504 *
1505 * @since 1.0.0
1506 *
1507 * @param array $settings Updated settings
1508 * @return bool True if website settings changed
1509 */
1510 private function has_website_settings_changed(array $settings): bool {
1511 // These are the keys the Website tab actually stores. It previously
1512 // looked for site_name/site_description/site_url, which belong to Site
1513 // Identity and never appear in a schema settings payload — so WebSite
1514 // schema never auto-deployed no matter what was edited (#455).
1515 $website_keys = [
1516 'website_name', 'website_url', 'website_description', 'website_author',
1517 ];
1518
1519 foreach ($website_keys as $key) {
1520 if (isset($settings[$key])) {
1521 return true;
1522 }
1523 }
1524
1525 return false;
1526 }
1527
1528 /**
1529 * Check if business settings have changed
1530 *
1531 * @since 1.0.0
1532 *
1533 * @param array $settings Updated settings
1534 * @return bool True if business settings changed
1535 */
1536 private function has_business_settings_changed(array $settings): bool {
1537 // Only the keys this manager actually stores. business_name/address/
1538 // phone/hours live in the site_identity category and never reach a
1539 // schema settings save, so keying off them meant LocalBusiness never
1540 // auto-deployed (#455). Edits to those fields refresh LocalBusiness
1541 // through the Site Identity save path instead — see
1542 // refresh_schema_for_foreign_settings().
1543 $business_keys = [
1544 'enable_local_business',
1545 'business_price_range',
1546 'business_geo_latitude',
1547 'business_geo_longitude',
1548 'business_opening_hours',
1549 ];
1550
1551 foreach ($business_keys as $key) {
1552 if (isset($settings[$key])) {
1553 return true;
1554 }
1555 }
1556
1557 return false;
1558 }
1559
1560 /**
1561 * Check if person settings have changed
1562 *
1563 * @since 1.0.0
1564 *
1565 * @param array $settings Updated settings
1566 * @return bool True if person settings changed
1567 */
1568 private function has_person_settings_changed(array $settings): bool {
1569 $person_keys = ['person_name', 'person_image', 'person_job_title', 'person_description'];
1570
1571 foreach ($person_keys as $key) {
1572 if (isset($settings[$key])) {
1573 return true;
1574 }
1575 }
1576
1577 return false;
1578 }
1579
1580 /**
1581 * Auto-detect appropriate schema types for context
1582 *
1583 * @since 1.0.0
1584 *
1585 * @param string $context_type Context type
1586 * @param int|null $context_id Context ID
1587 * @return array Detected schema types
1588 */
1589 private function auto_detect_schema_types(string $context_type, ?int $context_id): array {
1590 $detected_types = [];
1591
1592 switch ($context_type) {
1593 case 'site':
1594 // The admin's Schema Types selection is the answer to "what
1595 // does this site need"; detection is only the fallback for an
1596 // install that has not chosen yet (#456).
1597 $settings = $this->get_settings($context_type, $context_id);
1598 $enabled = array_values(array_filter(
1599 array_map('strval', (array) ($settings['enabled_schema_types'] ?? [])),
1600 'strlen'
1601 ));
1602
1603 // Drop stale names the factory no longer registers rather than
1604 // handing them to the builder to silently skip.
1605 $enabled = array_values(array_filter(
1606 $enabled,
1607 fn($type) => isset($this->schema_types[$type])
1608 ));
1609
1610 if (!empty($enabled)) {
1611 $detected_types = $enabled;
1612 break;
1613 }
1614
1615 $detected_types = ['Organization'];
1616 // Check if it's a local business
1617 if ($this->is_local_business()) {
1618 $detected_types[] = 'LocalBusiness';
1619 }
1620 break;
1621 case 'post':
1622 $detected_types = ['Article'];
1623 // Check content type for specific article types
1624 if ($context_id) {
1625 $post = get_post($context_id);
1626 if ($post && $this->is_how_to_content($post->post_content)) {
1627 $detected_types[] = 'HowTo';
1628 }
1629 }
1630 break;
1631 case 'page':
1632 $detected_types = ['Article'];
1633 if ($context_id) {
1634 $page = get_post($context_id);
1635 if ($page && $this->is_faq_content($page->post_content)) {
1636 $detected_types[] = 'FAQPage';
1637 }
1638 }
1639 break;
1640 case 'product':
1641 $detected_types = ['Product'];
1642 break;
1643 }
1644
1645 return $detected_types;
1646 }
1647
1648 /**
1649 * Prepare content data for Schema_Builder
1650 *
1651 * @since 1.0.0
1652 *
1653 * @param string $context_type Context type
1654 * @param int|null $context_id Context ID
1655 * @param array $content_analysis Content analysis data
1656 * @param array $optimization_data Optimization data
1657 * @param array $options Generation options (may contain custom content_data)
1658 * @return array Prepared content data for schema generation
1659 */
1660 private function prepare_content_data_for_generator(string $context_type, ?int $context_id, array $content_analysis, array $optimization_data, array $options = []): array {
1661 $content_data = [];
1662
1663 // Handle different context types
1664 if ($context_type === 'site') {
1665 // Site-level data
1666 $content_data = [
1667 'title' => get_bloginfo('name'),
1668 'url' => home_url(),
1669 'excerpt' => get_bloginfo('description'),
1670 'content' => get_bloginfo('description'),
1671 'business_data' => $this->get_business_data_from_local_seo(),
1672 'site_data' => $this->get_site_data_for_schema(),
1673 'social_data' => $this->get_social_data_for_schema()
1674 ];
1675 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1676 // Post/page/product data
1677 $post = get_post($context_id);
1678 if ($post) {
1679 // Resolve the featured image's URL to its ID here, where the ID
1680 // is in hand, so the schema builder does not query for an
1681 // attachment it was just given (#847). Offered as a hint rather
1682 // than asserted: `post_thumbnail_url` can swap the URL for one
1683 // the featured image does not own.
1684 $thumbnail_url = get_the_post_thumbnail_url($post->ID, 'full');
1685
1686 if ($thumbnail_url) {
1687 Attachment_Lookup::id_from_url((string) $thumbnail_url, (int) get_post_thumbnail_id($post->ID));
1688 }
1689
1690 $content_data = [
1691 'title' => $post->post_title,
1692 'url' => get_permalink($post->ID),
1693 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30),
1694 'content' => $post->post_content,
1695 'author' => [
1696 'name' => get_the_author_meta('display_name', $post->post_author),
1697 'url' => get_author_posts_url($post->post_author)
1698 ],
1699 // ISO 8601 with offset. post_date/post_modified are raw
1700 // MySQL columns in site-local time with no timezone, which
1701 // Google rejects as "Invalid value in field datePublished"
1702 // and drops the Article rich result (#465).
1703 'date' => get_the_date('c', $post),
1704 'modified' => get_the_modified_date('c', $post),
1705 'image' => $thumbnail_url,
1706 'focus_keywords' => Focus_Keywords::get($post->ID),
1707 'business_data' => $this->get_business_data_from_local_seo(),
1708 'site_data' => $this->get_site_data_for_schema(),
1709 'social_data' => $this->get_social_data_for_schema()
1710 ];
1711
1712 // Override with custom content data if provided (for metabox usage)
1713 if (!empty($options['content_data'])) {
1714 $custom_data = $options['content_data'];
1715
1716 // Override title if provided and not empty
1717 if (!empty($custom_data['title'])) {
1718 $content_data['title'] = $custom_data['title'];
1719 }
1720
1721 // Override excerpt/description if provided and not empty
1722 if (!empty($custom_data['description'])) {
1723 $content_data['excerpt'] = $custom_data['description'];
1724 }
1725
1726 // Override content if provided and not empty
1727 if (!empty($custom_data['content'])) {
1728 $content_data['content'] = $custom_data['content'];
1729 }
1730
1731 // Override URL if provided and not empty, but ensure it's the post permalink, not admin URL
1732 if (!empty($custom_data['post_url'])) {
1733 // If the URL is an admin edit URL, convert it to the post permalink
1734 if (strpos($custom_data['post_url'], 'wp-admin/post.php') !== false && $context_id) {
1735 $content_data['url'] = get_permalink($context_id);
1736 } else {
1737 $content_data['url'] = $custom_data['post_url'];
1738 }
1739 }
1740
1741 // Add focus keyword(s) if provided
1742 if (!empty($custom_data['focus_keywords']) && is_array($custom_data['focus_keywords'])) {
1743 $content_data['focus_keywords'] = $custom_data['focus_keywords'];
1744 }
1745 if (!empty($custom_data['focus_keyword'])) {
1746 $content_data['focus_keyword'] = $custom_data['focus_keyword'];
1747 }
1748
1749 // Add word count if provided (from frontend calculation)
1750 if (!empty($custom_data['word_count'])) {
1751 $content_data['word_count'] = (int) $custom_data['word_count'];
1752 }
1753
1754 // CRITICAL: Override site_data fields that take precedence in schema builder
1755 // The schema builder checks site_data first, so we need to clear these
1756 // to ensure our custom data is used instead
1757 if (isset($content_data['site_data'])) {
1758 // Clear site-level article settings so custom data takes precedence
1759 unset($content_data['site_data']['article_headline']);
1760 unset($content_data['site_data']['article_description']);
1761 unset($content_data['site_data']['article_author']);
1762 }
1763 }
1764
1765 // Merge schema form data into site_data if provided (for content-specific schemas)
1766 if (!empty($options['schema_form_data'])) {
1767 $form_data = $options['schema_form_data'];
1768
1769 // Ensure site_data exists
1770 if (!isset($content_data['site_data'])) {
1771 $content_data['site_data'] = [];
1772 }
1773
1774 // Merge form data into site_data so schema builder can access it
1775 $content_data['site_data'] = array_merge($content_data['site_data'], $form_data);
1776 }
1777
1778 // Simplified: Content analysis moved to separate services
1779 // Word count and reading time handled by Schema_Builder directly from content
1780 }
1781 }
1782
1783 return $content_data;
1784 }
1785
1786 /**
1787 * Store schema data in database
1788 *
1789 * @since 1.0.0
1790 *
1791 * @param string $context_type Context type
1792 * @param int|null $context_id Context ID
1793 * @param array $generation Generation results
1794 * @return bool Success status
1795 */
1796 private function store_schema_data(string $context_type, ?int $context_id, array $generation): bool {
1797 global $wpdb;
1798
1799 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1800
1801 // Replace only the types in this batch. Clearing the whole context
1802 // destroyed types the caller never asked about — and callers do
1803 // regenerate a subset, one type at a time (#454).
1804 $generated_types = array_keys($generation['generated_schemas'] ?? []);
1805 if (empty($generated_types)) {
1806 return false;
1807 }
1808 $this->delete_existing_schemas($context_type, $context_id, $generated_types);
1809
1810 foreach ($generation['generated_schemas'] as $schema_type => $schema_data) {
1811 // Prepare schema data with validation status embedded
1812 $schema_data_with_validation = $schema_data;
1813 $schema_data_with_validation['_validation'] = [
1814 'is_valid' => $generation['validation_results'][$schema_type]['is_valid'],
1815 'errors' => $generation['validation_results'][$schema_type]['errors'] ?? [],
1816 'warnings' => $generation['validation_results'][$schema_type]['warnings'] ?? [],
1817 'score' => $generation['validation_results'][$schema_type]['validation_score'] ?? 0
1818 ];
1819
1820 $data = [
1821 'context_type' => $context_type,
1822 'context_id' => $context_id,
1823 'schema_type' => $schema_type,
1824 'schema_data' => wp_json_encode($schema_data_with_validation),
1825 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid',
1826 // Per-type, not batch-wide. deployment_ready is only true when
1827 // EVERY type in the batch validated, so one invalid type (a site
1828 // with no Business Info makes LocalBusiness invalid) deactivated
1829 // all the valid ones alongside it (#470).
1830 'is_active' => !empty($generation['validation_results'][$schema_type]['is_valid']) ? 1 : 0
1831 ];
1832
1833 // Insert new schema (existing ones were already deleted)
1834 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
1835 $wpdb->insert($table_name, $data);
1836 }
1837
1838 // CACHE INVALIDATION: Clear cache after storing new schema data
1839 if ($this->cache_manager) {
1840 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
1841 }
1842
1843 return true;
1844 }
1845
1846 /**
1847 * Calculate validation score
1848 *
1849 * @since 1.0.0
1850 *
1851 * @param array $validation Validation results
1852 * @return int Score (0-100)
1853 */
1854 private function calculate_validation_score(array $validation): int {
1855 $score = 100;
1856 $score -= count($validation['errors'] ?? []) * 20;
1857 $score -= count($validation['warnings'] ?? []) * 10;
1858 $score -= count($validation['suggestions'] ?? []) * 5;
1859
1860 return max(0, (int) round($score));
1861 }
1862
1863 /**
1864 * Simple implementations for helper methods referenced in the main functions
1865 * These would be enhanced with more sophisticated algorithms in production
1866 */
1867
1868 // Removed complex AI integration methods - moved to separate services
1869 // Schema management focuses on core structured data generation
1870 private function generate_rich_snippets_preview(array $schema_data, string $schema_type): array {
1871 return [
1872 'preview_type' => $schema_type,
1873 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title',
1874 'description' => $schema_data['description'] ?? 'Description',
1875 'image' => $schema_data['image']['url'] ?? $schema_data['image'] ?? null,
1876 'additional_info' => $this->extract_additional_info($schema_data, $schema_type)
1877 ];
1878 }
1879
1880 private function extract_additional_info(array $schema_data, string $schema_type): array {
1881 $info = [];
1882
1883 switch ($schema_type) {
1884 case 'Article':
1885 if (isset($schema_data['author']['name'])) {
1886 $info['author'] = $schema_data['author']['name'];
1887 }
1888 if (isset($schema_data['datePublished'])) {
1889 $timestamp = strtotime($schema_data['datePublished']);
1890 if ($timestamp !== false) {
1891 $info['date'] = gmdate('M j, Y', $timestamp);
1892 }
1893 }
1894 break;
1895 case 'Product':
1896 if (isset($schema_data['offers']['price'])) {
1897 $info['price'] = $schema_data['offers']['priceCurrency'] . $schema_data['offers']['price'];
1898 }
1899 if (isset($schema_data['brand']['name'])) {
1900 $info['brand'] = $schema_data['brand']['name'];
1901 }
1902 break;
1903 }
1904
1905 return $info;
1906 }
1907
1908 private function generate_schema_optimization_recommendations(array $generated_schemas, array $validation_results): array {
1909 $recommendations = [];
1910
1911 foreach ($validation_results as $schema_type => $validation) {
1912 if (!$validation['is_valid']) {
1913 $recommendations[] = [
1914 'type' => 'validation_error',
1915 'schema_type' => $schema_type,
1916 'priority' => 'high',
1917 'message' => "Schema validation failed for {$schema_type}",
1918 'action' => 'Fix validation errors before deployment'
1919 ];
1920 }
1921
1922 if (!empty($validation['warnings'])) {
1923 // Extract missing properties from warnings
1924 $missing_properties = [];
1925 foreach ($validation['warnings'] as $warning) {
1926 if (strpos($warning, 'Missing recommended property:') === 0) {
1927 $property = trim(str_replace('Missing recommended property:', '', $warning));
1928 $missing_properties[] = $property;
1929 }
1930 }
1931
1932 if (!empty($missing_properties)) {
1933 $properties_list = implode(', ', $missing_properties);
1934 $recommendations[] = [
1935 'type' => 'missing_properties',
1936 'schema_type' => $schema_type,
1937 'priority' => 'medium',
1938 'message' => "Missing recommended properties for {$schema_type}: {$properties_list}",
1939 'action' => 'Add these properties to improve rich snippets eligibility'
1940 ];
1941 } else {
1942 $recommendations[] = [
1943 'type' => 'missing_properties',
1944 'schema_type' => $schema_type,
1945 'priority' => 'medium',
1946 'message' => "Missing recommended properties for {$schema_type}",
1947 'action' => 'Add recommended properties to improve rich snippets eligibility'
1948 ];
1949 }
1950 }
1951 }
1952
1953 return $recommendations;
1954 }
1955
1956 private function check_deployment_readiness(array $validation_results): bool {
1957 foreach ($validation_results as $validation) {
1958 if (!$validation['is_valid']) {
1959 return false;
1960 }
1961 }
1962 return true;
1963 }
1964
1965 // Content detection helper methods
1966 private function is_local_business(): bool {
1967 // Simple check - would be enhanced with actual business detection
1968 $description = get_bloginfo('description');
1969 $local_keywords = ['restaurant', 'shop', 'store', 'clinic', 'office', 'service'];
1970
1971 foreach ($local_keywords as $keyword) {
1972 if (stripos($description, $keyword) !== false) {
1973 return true;
1974 }
1975 }
1976
1977 return false;
1978 }
1979
1980 private function is_how_to_content(string $content): bool {
1981 $how_to_keywords = ['step', 'how to', 'tutorial', 'guide', 'instructions'];
1982 $content_lower = strtolower($content);
1983
1984 foreach ($how_to_keywords as $keyword) {
1985 if (stripos($content_lower, $keyword) !== false) {
1986 return true;
1987 }
1988 }
1989
1990 return false;
1991 }
1992
1993 private function is_faq_content(string $content): bool {
1994 $faq_keywords = ['faq', 'frequently asked', 'questions', 'q:', 'a:'];
1995 $content_lower = strtolower($content);
1996
1997 foreach ($faq_keywords as $keyword) {
1998 if (stripos($content_lower, $keyword) !== false) {
1999 return true;
2000 }
2001 }
2002
2003 return false;
2004 }
2005
2006 private function determine_deployment_method(array $options): string {
2007 // Always use JSON-LD as it's the only supported method
2008 return 'json_ld';
2009 }
2010
2011 private function deploy_single_schema(array $schema, string $schema_type, string $method, string $context_type, ?int $context_id): array {
2012 global $wpdb;
2013
2014 // Use existing seo_schema table
2015 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2016
2017 $deployment_data = [
2018 'context_type' => $context_type,
2019 'context_id' => $context_id,
2020 'schema_type' => $schema_type,
2021 'schema_data' => wp_json_encode($schema),
2022 'validation_status' => 'deployed',
2023 'is_active' => 1
2024 ];
2025
2026 // Check if schema already exists for this context and type
2027 if (null === $context_id) {
2028 // Handle NULL context_id case
2029 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access, table name is validated
2030 $sql = sprintf(
2031 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id IS NULL AND schema_type = %%s',
2032 $table_name
2033 );
2034 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access
2035 $existing = $wpdb->get_var(
2036 $wpdb->prepare(
2037 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2038 $sql,
2039 $context_type,
2040 $schema_type
2041 )
2042 );
2043 } else {
2044 // Handle regular context_id case
2045 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access, table name is validated
2046 $sql = sprintf(
2047 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id = %%d AND schema_type = %%s',
2048 $table_name
2049 );
2050 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access
2051 $existing = $wpdb->get_var(
2052 $wpdb->prepare(
2053 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2054 $sql,
2055 $context_type,
2056 $context_id,
2057 $schema_type
2058 )
2059 );
2060 }
2061
2062 if ($existing) {
2063 // Update existing deployment
2064 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema update requires direct database access
2065 $result = $wpdb->update(
2066 $table_name,
2067 [
2068 'schema_data' => wp_json_encode($schema),
2069 'validation_status' => 'deployed',
2070 'is_active' => 1,
2071 'updated_at' => current_time('mysql')
2072 ],
2073 ['schema_id' => $existing],
2074 ['%s', '%s', '%d', '%s'],
2075 ['%d']
2076 );
2077
2078 } else {
2079 // Insert new deployment
2080 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
2081 $result = $wpdb->insert(
2082 $table_name,
2083 $deployment_data,
2084 ['%s', '%d', '%s', '%s', '%s', '%d']
2085 );
2086
2087 }
2088
2089 return [
2090 'deployed' => $result !== false,
2091 'method' => $method,
2092 'schema_type' => $schema_type,
2093 'schema_id' => $existing ?: $wpdb->insert_id
2094 ];
2095 }
2096
2097 /**
2098 * Get deployed schemas for frontend integration
2099 *
2100 * Returns the newest active, deployed row of each schema type for the
2101 * context. Results are cached per context (see Schema_Cache_Manager).
2102 *
2103 * @since 1.0.0
2104 *
2105 * @param string $context_type Context type
2106 * @param int|null $context_id Context ID
2107 * @return array Deployed schemas for current context
2108 */
2109 public function get_deployed_schemas(string $context_type = 'site', ?int $context_id = null): array {
2110 // CACHE LAYER: Check cache first for immediate 90% performance improvement
2111 if ($this->cache_manager) {
2112 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
2113 $cached_data = $this->cache_manager->get($cache_key);
2114
2115 if ($cached_data !== null) {
2116 // CACHE FIX: Extract actual data from cache wrapper
2117 return isset($cached_data['data']) ? $cached_data['data'] : $cached_data;
2118 }
2119 }
2120
2121 global $wpdb;
2122
2123 // Use existing seo_schema table
2124 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2125
2126 // The newest row per type used to be picked with ROW_NUMBER() OVER
2127 // (PARTITION BY schema_type ...). Window functions need MySQL 8.0 /
2128 // MariaDB 10.2, and WordPress still runs on MySQL 5.7, where that is
2129 // a syntax error on every page view and no deployed schema is ever
2130 // output. A row is the newest of its type when no other row of the
2131 // same context and type outranks it, so NOT EXISTS keeps the
2132 // greatest-per-group in the database, and schema_data — JSON, and
2133 // large — is only transferred for the rows that are output.
2134 //
2135 // `<=>` is NULL-safe equality: the site context stores context_id
2136 // as NULL, and `n.context_id = s.context_id` is never true for it.
2137 // schema_id breaks a same-second tie, which the window function
2138 // left to chance.
2139 $args = [$context_type];
2140 if (null === $context_id) {
2141 $context_where = 's.context_id IS NULL';
2142 } else {
2143 $context_where = 's.context_id = %d';
2144 $args[] = $context_id;
2145 }
2146
2147 $sql = sprintf(
2148 'SELECT s.schema_type, s.schema_data FROM %1$s s'
2149 . ' WHERE s.context_type = %%s AND %2$s AND s.is_active = 1 AND s.validation_status IN (\'deployed\', \'valid\')'
2150 . ' AND NOT EXISTS ('
2151 . 'SELECT 1 FROM %1$s n'
2152 . ' WHERE n.context_type = s.context_type AND n.context_id <=> s.context_id AND n.schema_type = s.schema_type'
2153 . ' AND n.is_active = 1 AND n.validation_status IN (\'deployed\', \'valid\')'
2154 . ' AND (n.created_at > s.created_at OR (n.created_at = s.created_at AND n.schema_id > s.schema_id))'
2155 . ')'
2156 . ' ORDER BY s.schema_type',
2157 $table_name,
2158 $context_where
2159 );
2160
2161 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2162 $deployed_schemas = $wpdb->get_results(
2163 $wpdb->prepare(
2164 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2165 $sql,
2166 ...$args
2167 ),
2168 ARRAY_A
2169 );
2170
2171 // Deliberately no early return on an empty result: it has to reach the
2172 // cache write below. Most URLs have no deployed schema, so gating the
2173 // write on a non-empty result made the majority of front-end requests
2174 // permanent cache misses, re-running the query on every pageview (#392).
2175 $deployed_schemas = $deployed_schemas ?: [];
2176
2177 // Process schemas for return
2178 $processed_schemas = [];
2179 foreach ($deployed_schemas as $deployed_schema) {
2180 $schema_data = json_decode($deployed_schema['schema_data'], true);
2181 $schema_type = $deployed_schema['schema_type'];
2182
2183 if (!empty($schema_data)) {
2184 // Remove internal validation metadata before frontend output
2185 if (isset($schema_data['_validation'])) {
2186 unset($schema_data['_validation']);
2187 }
2188
2189 // Deployed schema is a snapshot, so rows written before #465
2190 // still carry raw MySQL datetimes. Normalise on read so the
2191 // fix reaches existing sites without a migration.
2192 $schema_data = $this->normalize_stored_schema($schema_data);
2193
2194 // The permalink was frozen at deploy time, so schema deployed
2195 // while a post was a draft advertised "?p=123" as both url and
2196 // mainEntityOfPage forever — contradicting the node's own @id
2197 // and the canonical (#470). Resolve it live instead.
2198 $schema_data = $this->refresh_schema_permalink($schema_data, $context_type, $context_id);
2199
2200 // schema.org types `sameAs`, `url`, `logo` and `image` as URLs,
2201 // but the form stored whatever was typed, so free text entered
2202 // in a social-profile field shipped as a sameAs member and made
2203 // the whole entity invalid (#480). Drop bad values on read, so
2204 // existing sites stop emitting them without a migration.
2205 $schema_data = $this->filter_entity_urls($schema_data);
2206
2207 $processed_schemas[$schema_type] = [
2208 'data' => $schema_data,
2209 'method' => 'json_ld', // Default method
2210 'type' => $schema_type
2211 ];
2212 }
2213 }
2214
2215 // CACHE LAYER: Store result in cache for future requests — including
2216 // an empty one. Cache_Manager::set() wraps the payload in a metadata
2217 // envelope, so an empty result is still stored as a truthy value and
2218 // reads back as a hit rather than a miss (#392).
2219 if ($this->cache_manager) {
2220 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
2221 $this->cache_manager->set($cache_key, $processed_schemas);
2222 }
2223
2224 return $processed_schemas;
2225 }
2226
2227 /**
2228 * Properties schema.org defines as URLs.
2229 *
2230 * @since 2.0.2
2231 * @var string[]
2232 */
2233 private const URL_PROPERTIES = ['sameAs', 'url', 'logo', 'image'];
2234
2235 /**
2236 * Whether a value is a URL safe to publish in structured data.
2237 *
2238 * @since 2.0.2
2239 *
2240 * @param mixed $url Candidate value.
2241 * @return bool
2242 */
2243 private function is_publishable_url($url): bool {
2244 if (!is_string($url) || '' === trim($url)) {
2245 return false;
2246 }
2247
2248 if (!filter_var($url, FILTER_VALIDATE_URL)) {
2249 return false;
2250 }
2251
2252 $scheme = wp_parse_url($url, PHP_URL_SCHEME);
2253
2254 return in_array(strtolower((string) $scheme), ['http', 'https'], true);
2255 }
2256
2257 /**
2258 * Drop values that are not URLs from URL-typed properties.
2259 *
2260 * An absent property is valid; one holding free text is not, and it can
2261 * invalidate the entity around it. Nested objects (`logo` and `image` are
2262 * frequently ImageObjects) are walked so a bad `url` inside one is caught
2263 * too. A property left with nothing is removed rather than emitted empty.
2264 *
2265 * @since 2.0.2
2266 *
2267 * @param array $schema Decoded schema data.
2268 * @return array Schema carrying only publishable URLs.
2269 */
2270 private function filter_entity_urls(array $schema): array {
2271 foreach ($schema as $key => $value) {
2272 if (is_array($value) && !in_array($key, self::URL_PROPERTIES, true)) {
2273 $schema[$key] = $this->filter_entity_urls($value);
2274 continue;
2275 }
2276
2277 if (!in_array($key, self::URL_PROPERTIES, true)) {
2278 continue;
2279 }
2280
2281 // A nested object (ImageObject and friends) carries its own url.
2282 if (is_array($value) && isset($value['@type'])) {
2283 $schema[$key] = $this->filter_entity_urls($value);
2284 continue;
2285 }
2286
2287 if (is_array($value)) {
2288 $kept = [];
2289
2290 foreach ($value as $item) {
2291 if (is_array($item)) {
2292 $kept[] = $this->filter_entity_urls($item);
2293 } elseif ($this->is_publishable_url($item)) {
2294 $kept[] = $item;
2295 }
2296 }
2297
2298 if ([] === $kept) {
2299 unset($schema[$key]);
2300 } else {
2301 $schema[$key] = array_values($kept);
2302 }
2303
2304 continue;
2305 }
2306
2307 if (!$this->is_publishable_url($value)) {
2308 unset($schema[$key]);
2309 }
2310 }
2311
2312 return $schema;
2313 }
2314
2315 /**
2316 * Schema types whose `url` identifies the entity, not the page.
2317 *
2318 * On a Person or an Organization, `url` is that entity's own website, so
2319 * overwriting it with the permalink of whichever post the schema happens to
2320 * be deployed on is simply wrong. It also breaks graph assembly: the site
2321 * identity emits the same entity with its real `url`, and once the two
2322 * copies disagree they can no longer be recognised as one entity (#479).
2323 *
2324 * @since 2.0.2
2325 * @var string[]
2326 */
2327 private const ENTITY_URL_TYPES = ['Person', 'Organization', 'LocalBusiness'];
2328
2329 /**
2330 * Replace a stored permalink snapshot with the post's live permalink.
2331 *
2332 * Only touches `url` and `mainEntityOfPage`, and only for post-like
2333 * contexts where a permalink actually exists. Identity entities are
2334 * exempt from the `url` rewrite — see self::ENTITY_URL_TYPES.
2335 *
2336 * @since 1.16.0
2337 *
2338 * @param array $schema Decoded schema data.
2339 * @param string $context_type Context type.
2340 * @param int|null $context_id Context ID.
2341 * @return array Schema with a current permalink.
2342 */
2343 private function refresh_schema_permalink(array $schema, string $context_type, ?int $context_id): array {
2344 if ('site' === $context_type || empty($context_id)) {
2345 return $schema;
2346 }
2347
2348 $permalink = get_permalink($context_id);
2349
2350 if (!$permalink) {
2351 return $schema;
2352 }
2353
2354 $type = $schema['@type'] ?? '';
2355 $type = is_array($type) ? reset($type) : $type;
2356 // A LocalBusiness is deployed under the subtype the site chose, so the
2357 // exemption has to cover every subtype, not only the literal root.
2358 $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true)
2359 || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type);
2360
2361 if (isset($schema['url']) && !$is_entity) {
2362 $schema['url'] = $permalink;
2363 }
2364
2365 if (isset($schema['mainEntityOfPage'])) {
2366 if (is_array($schema['mainEntityOfPage'])) {
2367 if (isset($schema['mainEntityOfPage']['@id'])) {
2368 $schema['mainEntityOfPage']['@id'] = $permalink;
2369 }
2370 } else {
2371 $schema['mainEntityOfPage'] = $permalink;
2372 }
2373 }
2374
2375 return $schema;
2376 }
2377
2378 /**
2379 * Normalise properties that stored snapshots may hold in a stale format.
2380 *
2381 * Deployed schema is written once and read forever, so a formatting fix in
2382 * the builder never reaches rows already on disk. Correcting on read means
2383 * existing sites benefit without a migration.
2384 *
2385 * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must
2386 * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values
2387 * inside author/publisher/@graph entries are covered too.
2388 *
2389 * Also decodes HTML entities in plain-text properties. Schema_Builder
2390 * stored the block editor's `&amp;` as-is until 2.10.0, and nothing
2391 * decodes JSON-LD downstream, so every deployed node built from post text
2392 * published the entity literally.
2393 *
2394 * @since 1.16.0
2395 * @since 2.10.0 Decodes entities in plain-text properties.
2396 *
2397 * @param array $schema Decoded schema data.
2398 * @return array Normalised schema.
2399 */
2400 private function normalize_stored_schema(array $schema): array {
2401 static $date_keys = [
2402 'datePublished', 'dateModified', 'dateCreated', 'uploadDate',
2403 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires',
2404 ];
2405
2406 // Plain text in schema.org. Answer/HowToStep `text` is deliberately
2407 // absent: Google reads Answer.text as HTML, where an entity is correct
2408 // and decoding `&lt;` would turn escaped text into live markup.
2409 static $text_keys = [
2410 'name', 'headline', 'alternativeHeadline', 'description',
2411 'reviewBody', 'about', 'abstract', 'caption',
2412 ];
2413
2414 foreach ($schema as $key => $value) {
2415 if (is_array($value)) {
2416 $schema[$key] = $this->normalize_stored_schema($value);
2417 continue;
2418 }
2419
2420 if ('inLanguage' === $key && is_string($value) && '' !== $value) {
2421 $schema[$key] = str_replace('_', '-', $value);
2422 continue;
2423 }
2424
2425 // Decode only: a snapshot already truncated with an ellipsis must
2426 // keep it, which the full Seo_Text::normalize_schema_text() would
2427 // strip as an excerpt marker.
2428 if (in_array($key, $text_keys, true) && is_string($value) && '' !== $value) {
2429 $schema[$key] = \ThinkRank\Core\Seo_Text::decode_schema_entities($value);
2430 continue;
2431 }
2432
2433 if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) {
2434 continue;
2435 }
2436
2437 // Already ISO 8601 — leave it alone.
2438 if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $value)) {
2439 continue;
2440 }
2441
2442 $timestamp = strtotime($value);
2443
2444 if (false !== $timestamp) {
2445 $schema[$key] = (string) wp_date('c', $timestamp);
2446 }
2447 }
2448
2449 return $schema;
2450 }
2451
2452 /**
2453 * Clean up duplicate schemas in database
2454 *
2455 * @since 1.0.0
2456 *
2457 * @param string $context_type Context type
2458 * @param int|null $context_id Context ID
2459 * @return int Number of duplicate schemas removed
2460 */
2461 public function cleanup_duplicate_schemas(string $context_type = 'site', ?int $context_id = null): int {
2462 global $wpdb;
2463
2464 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2465
2466 if (null === $context_id) {
2467 // Clean up duplicates for NULL context_id
2468 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
2469 $sql = sprintf(
2470 'DELETE t1 FROM %s t1 INNER JOIN %s t2 WHERE t1.context_type = %%s AND t1.context_id IS NULL AND t2.context_type = %%s AND t2.context_id IS NULL AND t1.schema_type = t2.schema_type AND t1.created_at < t2.created_at',
2471 $table_name,
2472 $table_name
2473 );
2474 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
2475 $deleted = $wpdb->query(
2476 $wpdb->prepare(
2477 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2478 $sql,
2479 $context_type,
2480 $context_type
2481 )
2482 );
2483 } else {
2484 // Clean up duplicates for specific context_id
2485 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
2486 $sql = sprintf(
2487 'DELETE t1 FROM %s t1 INNER JOIN %s t2 WHERE t1.context_type = %%s AND t1.context_id = %%d AND t2.context_type = %%s AND t2.context_id = %%d AND t1.schema_type = t2.schema_type AND t1.created_at < t2.created_at',
2488 $table_name,
2489 $table_name
2490 );
2491 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
2492 $deleted = $wpdb->query(
2493 $wpdb->prepare(
2494 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2495 $sql,
2496 $context_type,
2497 $context_id,
2498 $context_type,
2499 $context_id
2500 )
2501 );
2502 }
2503
2504 return $deleted ?: 0;
2505 }
2506
2507 /**
2508 * Deactivate deployed schema rows for the given types.
2509 *
2510 * Deployment was insert-only, so anything ever deployed to a context stayed
2511 * on the page forever — switching a post's schema type left the old one live
2512 * and deactivating a saved schema did nothing (#464). Rows are deactivated
2513 * rather than deleted so a later redeploy can revive them and so there is a
2514 * trail of what was published.
2515 *
2516 * @since 1.16.0
2517 *
2518 * @param string $context_type Context type.
2519 * @param int|null $context_id Context ID.
2520 * @param string[] $schema_types Types to retire.
2521 * @return int Number of rows deactivated.
2522 */
2523 private function retire_schema_types(string $context_type, ?int $context_id, array $schema_types): int {
2524 $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
2525
2526 if (empty($schema_types)) {
2527 return 0;
2528 }
2529
2530 global $wpdb;
2531
2532 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2533 $placeholders = implode(', ', array_fill(0, count($schema_types), '%s'));
2534
2535 if (null === $context_id) {
2536 $sql = sprintf(
2537 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id IS NULL AND schema_type IN (%s)',
2538 $table_name,
2539 $placeholders
2540 );
2541 $args = array_merge([$context_type], $schema_types);
2542 } else {
2543 $sql = sprintf(
2544 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id = %%d AND schema_type IN (%s)',
2545 $table_name,
2546 $placeholders
2547 );
2548 $args = array_merge([$context_type, $context_id], $schema_types);
2549 }
2550
2551 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Retiring deployed schema rows requires direct database access.
2552 $updated = $wpdb->query(
2553 $wpdb->prepare(
2554 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is built from an internal table name and generated placeholders.
2555 $sql,
2556 $args
2557 )
2558 );
2559
2560 if ($updated && $this->cache_manager) {
2561 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
2562 }
2563
2564 return (int) ($updated ?: 0);
2565 }
2566
2567 /**
2568 * Retire deployed types that are no longer in the user's Schema Types selection.
2569 *
2570 * An empty selection means "auto-detect", so nothing is retired in that case.
2571 *
2572 * @since 1.16.0
2573 *
2574 * @param string $context_type Context type.
2575 * @param int|null $context_id Context ID.
2576 * @param array $enabled_types The user's selected types.
2577 * @return int Number of rows deactivated.
2578 */
2579 private function retire_unselected_schema_types(string $context_type, ?int $context_id, array $enabled_types): int {
2580 if (empty($enabled_types)) {
2581 return 0;
2582 }
2583
2584 $deployed = array_keys($this->get_deployed_schemas($context_type, $context_id));
2585 $stale = array_diff($deployed, $enabled_types);
2586
2587 return $this->retire_schema_types($context_type, $context_id, $stale);
2588 }
2589
2590 /**
2591 * Delete stored schemas for a context before storing new ones.
2592 *
2593 * `$schema_types` scopes the delete to the types actually being rewritten.
2594 * Without it this wiped every type in the context, which silently destroyed
2595 * deployed schema whenever a caller regenerated a subset — and
2596 * auto_deploy_schema_on_settings_change() regenerates one type at a time
2597 * (#454). Passing an empty array keeps the original clear-the-context
2598 * behaviour for callers that genuinely rewrite everything.
2599 *
2600 * @since 1.0.0
2601 *
2602 * @param string $context_type Context type
2603 * @param int|null $context_id Context ID
2604 * @param string[] $schema_types Optional. Limit the delete to these types.
2605 * @return int Number of schemas deleted
2606 */
2607 private function delete_existing_schemas(string $context_type, ?int $context_id, array $schema_types = []): int {
2608 global $wpdb;
2609
2610 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2611
2612 // Build an optional `AND schema_type IN (…)` clause with one prepared
2613 // placeholder per type, so the scoping cannot be injected through.
2614 $type_clause = '';
2615 $type_values = [];
2616 $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
2617 if (!empty($schema_types)) {
2618 $type_clause = ' AND schema_type IN (' . implode(', ', array_fill(0, count($schema_types), '%s')) . ')';
2619 $type_values = $schema_types;
2620 }
2621
2622 if (null === $context_id) {
2623 // Delete all schemas for NULL context_id
2624 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
2625 $sql = sprintf(
2626 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL%s',
2627 $table_name,
2628 $type_clause
2629 );
2630 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
2631 $deleted = $wpdb->query(
2632 $wpdb->prepare(
2633 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2634 $sql,
2635 array_merge([$context_type], $type_values)
2636 )
2637 );
2638 } else {
2639 // Delete all schemas for specific context_id
2640 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
2641 $sql = sprintf(
2642 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d%s',
2643 $table_name,
2644 $type_clause
2645 );
2646 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
2647 $deleted = $wpdb->query(
2648 $wpdb->prepare(
2649 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2650 $sql,
2651 array_merge([$context_type, $context_id], $type_values)
2652 )
2653 );
2654 }
2655
2656 return $deleted ?: 0;
2657 }
2658
2659 /**
2660 * Get business data from Site Identity Local settings
2661 *
2662 * @return array
2663 */
2664 private function get_business_data_from_local_seo(): array {
2665 // Get Site Identity settings which include Local SEO data
2666 $site_identity_settings = get_option('thinkrank_site_identity_settings', []);
2667
2668 return [
2669 'business_name' => $site_identity_settings['business_name'] ?? '',
2670 'business_address' => $site_identity_settings['business_address'] ?? '',
2671 'business_city' => $site_identity_settings['business_city'] ?? '',
2672 'business_state' => $site_identity_settings['business_state'] ?? '',
2673 'business_postal_code' => $site_identity_settings['business_postal_code'] ?? '',
2674 'business_country' => $site_identity_settings['business_country'] ?? '',
2675 'business_phone' => $site_identity_settings['business_phone'] ?? '',
2676 'business_email' => $site_identity_settings['business_email'] ?? '',
2677 'business_hours' => $site_identity_settings['business_hours'] ?? [],
2678 'business_type' => $site_identity_settings['business_type'] ?? 'LocalBusiness'
2679 ];
2680 }
2681 public function get_settings(string $context_type, ?int $context_id = null): array {
2682 // Get base settings from parent
2683 $settings = parent::get_settings($context_type, $context_id);
2684
2685 // For site context, automatically include Site Identity data
2686 if ($context_type === 'site') {
2687 // Get Site Identity settings from the Site Identity Manager
2688 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
2689 $site_identity_settings = $site_identity_manager->get_settings('site', null);
2690
2691 // Include Site Identity assets if not already set in Schema Manager
2692 if (empty($settings['logo_url']) && !empty($site_identity_settings['logo_url'])) {
2693 $settings['logo_url'] = $site_identity_settings['logo_url'];
2694 }
2695 if (empty($settings['favicon_url']) && !empty($site_identity_settings['favicon_url'])) {
2696 $settings['favicon_url'] = $site_identity_settings['favicon_url'];
2697 }
2698 if (empty($settings['apple_touch_icon_url']) && !empty($site_identity_settings['apple_touch_icon_url'])) {
2699 $settings['apple_touch_icon_url'] = $site_identity_settings['apple_touch_icon_url'];
2700 }
2701
2702 // Include Site Identity organization data if not already set in Schema Manager
2703 if (empty($settings['organization_name']) && !empty($site_identity_settings['site_name'])) {
2704 $settings['organization_name'] = $site_identity_settings['site_name'];
2705 }
2706 if (empty($settings['organization_url']) && !empty($site_identity_settings['site_url'])) {
2707 $settings['organization_url'] = $site_identity_settings['site_url'];
2708 }
2709 if (empty($settings['organization_description']) && !empty($site_identity_settings['site_description'])) {
2710 $settings['organization_description'] = $site_identity_settings['site_description'];
2711 }
2712 }
2713
2714 return $settings;
2715 }
2716
2717 /**
2718 * Get site data for rich schema generation
2719 *
2720 * @return array
2721 */
2722 private function get_site_data_for_schema(): array {
2723 // Get Schema Manager's own settings first (highest priority)
2724 $schema_settings = $this->get_settings('site', null);
2725
2726 // Get Site Identity settings for additional data
2727 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
2728 $site_identity_settings = $site_identity_manager->get_settings('site', null);
2729
2730 return [
2731 'site_name' => get_bloginfo('name'),
2732 'site_description' => get_bloginfo('description'),
2733 'site_url' => home_url(),
2734 'admin_email' => get_option('admin_email'),
2735 'language' => get_locale(),
2736 'timezone' => get_option('timezone_string'),
2737 // Read by populate_website_schema(), so the deployed WebSite node
2738 // carries the same alternateName as the default one (#692).
2739 'alternate_name' => $site_identity_settings['alternate_name'] ?? '',
2740 'founded_date' => $site_identity_settings['founded_date'] ?? '',
2741 'founder_name' => $site_identity_settings['founder_name'] ?? '',
2742 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
2743 // Site Identity assets
2744 'logo_url' => $site_identity_settings['logo_url'] ?? '',
2745 'favicon_url' => $site_identity_settings['favicon_url'] ?? '',
2746 // Schema Manager organization settings (highest priority)
2747 'organization_name' => $schema_settings['organization_name'] ?? '',
2748 'organization_description' => $schema_settings['organization_description'] ?? '',
2749 'organization_url' => $schema_settings['organization_url'] ?? '',
2750
2751 // Removed post/page-specific schema settings (Product, Event, Article, Software Application)
2752 // These are now handled only at the post/page level via metabox
2753
2754 // Person schema settings (site-wide)
2755 'person_name' => $schema_settings['person_name'] ?? '',
2756 'person_job_title' => $schema_settings['person_job_title'] ?? '',
2757 'person_description' => $schema_settings['person_description'] ?? '',
2758 'person_image' => $schema_settings['person_image'] ?? '',
2759 'person_url' => $schema_settings['person_url'] ?? '',
2760 'person_email' => $schema_settings['person_email'] ?? '',
2761 'person_telephone' => $schema_settings['person_telephone'] ?? '',
2762 'person_address' => $schema_settings['person_address'] ?? '',
2763 'person_birth_date' => $schema_settings['person_birth_date'] ?? '',
2764 'person_nationality' => $schema_settings['person_nationality'] ?? '',
2765 'person_works_for' => $schema_settings['person_works_for'] ?? '',
2766 'person_same_as' => $schema_settings['person_same_as'] ?? [],
2767
2768 // Website schema settings (site-wide)
2769 'website_name' => $schema_settings['website_name'] ?? '',
2770 'website_url' => $schema_settings['website_url'] ?? '',
2771 'website_description' => $schema_settings['website_description'] ?? '',
2772 'website_author' => $schema_settings['website_author'] ?? '',
2773
2774 'organization_logo' => $schema_settings['organization_logo'] ?? '',
2775 // Social media links (sameAs)
2776 'organization_social_facebook' => $schema_settings['organization_social_facebook'] ?? '',
2777 'organization_social_twitter' => $schema_settings['organization_social_twitter'] ?? '',
2778 'organization_social_linkedin' => $schema_settings['organization_social_linkedin'] ?? '',
2779 'organization_social_instagram' => $schema_settings['organization_social_instagram'] ?? '',
2780 'organization_social_youtube' => $schema_settings['organization_social_youtube'] ?? '',
2781 'organization_social_pinterest' => $schema_settings['organization_social_pinterest'] ?? '',
2782 'organization_social_whatsapp' => $schema_settings['organization_social_whatsapp'] ?? '',
2783 'organization_social_telegram' => $schema_settings['organization_social_telegram'] ?? '',
2784 // Contact point information
2785 'organization_contact_type' => $schema_settings['organization_contact_type'] ?? 'customer service',
2786 'organization_contact_phone' => $schema_settings['organization_contact_phone'] ?? '',
2787 'organization_contact_email' => $schema_settings['organization_contact_email'] ?? '',
2788 'organization_contact_hours' => $schema_settings['organization_contact_hours'] ?? '',
2789 // LocalBusiness specific fields
2790 'business_price_range' => $schema_settings['business_price_range'] ?? '',
2791 'business_geo_latitude' => $schema_settings['business_geo_latitude'] ?? '',
2792 'business_geo_longitude' => $schema_settings['business_geo_longitude'] ?? '',
2793 'business_opening_hours' => $schema_settings['business_opening_hours'] ?? []
2794 ];
2795 }
2796
2797 /**
2798 * Get social media data for schema generation
2799 *
2800 * @return array
2801 */
2802 private function get_social_data_for_schema(): array {
2803 // Get Social Media settings
2804 $social_settings = get_option('thinkrank_social_media_settings', []);
2805
2806 $social_profiles = [];
2807
2808 // Common social platforms
2809 $platforms = ['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'tiktok', 'pinterest'];
2810
2811 foreach ($platforms as $platform) {
2812 $url = $social_settings["{$platform}_url"] ?? '';
2813 if (!empty($url)) {
2814 $social_profiles[] = $url;
2815 }
2816 }
2817
2818 return [
2819 'social_profiles' => $social_profiles,
2820 'social_settings' => $social_settings
2821 ];
2822 }
2823 }
2824