PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / 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.7.0, at includes/seo/class-schema-management-system.php

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