PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.1.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.1.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 1.1.0, at includes/seo/class-schema-management-system.php

2,167 lines 85.5 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 /**
21 * Schema Management System Class
22 *
23 * Provides streamlined schema markup management with generation, validation,
24 * rich snippets optimization, and automated deployment. Focuses on core
25 * structured data creation with clean separation of concerns.
26 *
27 * @since 1.0.0
28 */
29 class Schema_Management_System extends Abstract_SEO_Manager {
30
31 /**
32 * Schema.org types with 2025 specifications
33 *
34 * @since 1.0.0
35 * @var array
36 */
37 private array $schema_types = [
38 'Article' => [
39 'name' => 'Article',
40 'description' => 'News articles, blog posts, and editorial content',
41 'required_properties' => ['headline', 'author', 'datePublished'],
42 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage'],
43 'rich_snippets' => ['article', 'news_article', 'blog_posting'],
44 'context_types' => ['post', 'page'],
45 'priority' => 'high'
46 ],
47 'BlogPosting' => [
48 'name' => 'BlogPosting',
49 'description' => 'Blog posts and personal articles',
50 'required_properties' => ['headline', 'author', 'datePublished'],
51 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage'],
52 'rich_snippets' => ['article', 'blog_posting'],
53 'context_types' => ['post', 'page'],
54 'priority' => 'high'
55 ],
56 'TechnicalArticle' => [
57 'name' => 'TechnicalArticle',
58 'description' => 'Technical documentation and tutorials',
59 'required_properties' => ['headline', 'author', 'datePublished'],
60 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'dependencies', 'proficiencyLevel'],
61 'rich_snippets' => ['article', 'technical_article'],
62 'context_types' => ['post', 'page'],
63 'priority' => 'high'
64 ],
65 'NewsArticle' => [
66 'name' => 'NewsArticle',
67 'description' => 'News articles and press releases',
68 'required_properties' => ['headline', 'author', 'datePublished'],
69 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'dateline'],
70 'rich_snippets' => ['article', 'news_article'],
71 'context_types' => ['post', 'page'],
72 'priority' => 'high'
73 ],
74 'ScholarlyArticle' => [
75 'name' => 'ScholarlyArticle',
76 'description' => 'Academic and research articles',
77 'required_properties' => ['headline', 'author', 'datePublished'],
78 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'citation', 'abstract'],
79 'rich_snippets' => ['article', 'scholarly_article'],
80 'context_types' => ['post', 'page'],
81 'priority' => 'high'
82 ],
83 'Report' => [
84 'name' => 'Report',
85 'description' => 'Reports and analytical content',
86 'required_properties' => ['headline', 'author', 'datePublished'],
87 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage'],
88 'rich_snippets' => ['article', 'report'],
89 'context_types' => ['post', 'page'],
90 'priority' => 'medium'
91 ],
92 'Product' => [
93 'name' => 'Product',
94 'description' => 'Products for e-commerce and retail',
95 'required_properties' => ['name', 'description'],
96 'recommended_properties' => ['image', 'offers', 'brand', 'sku', 'gtin', 'review', 'aggregateRating'],
97 'rich_snippets' => ['product', 'offer', 'review'],
98 'context_types' => ['product', 'page', 'post'],
99 'priority' => 'critical'
100 ],
101 'LocalBusiness' => [
102 'name' => 'LocalBusiness',
103 'description' => 'Local businesses and service providers',
104 'required_properties' => ['name', 'address'],
105 'recommended_properties' => ['telephone', 'url', 'openingHours', 'geo', 'priceRange'],
106 'rich_snippets' => ['local_business', 'organization'],
107 'context_types' => ['site', 'page'],
108 'priority' => 'high'
109 ],
110 'Organization' => [
111 'name' => 'Organization',
112 'description' => 'Companies, corporations, and institutions',
113 'required_properties' => ['name', 'url'],
114 'recommended_properties' => ['logo', 'contactPoint', 'sameAs', 'address'],
115 'rich_snippets' => ['organization', 'corporation'],
116 'context_types' => ['site'],
117 'priority' => 'medium'
118 ],
119 'WebSite' => [
120 'name' => 'WebSite',
121 'description' => 'Website and web application information',
122 'required_properties' => ['name', 'url'],
123 'recommended_properties' => ['description', 'author', 'publisher', 'potentialAction'],
124 'rich_snippets' => ['website', 'sitelinks_searchbox'],
125 'context_types' => ['site'],
126 'priority' => 'high'
127 ],
128 'SoftwareApplication' => [
129 'name' => 'SoftwareApplication',
130 'description' => 'Software applications and web apps',
131 'required_properties' => ['name', 'applicationCategory'],
132 'recommended_properties' => ['description', 'url', 'offers', 'creator', 'features', 'aggregateRating'],
133 'rich_snippets' => ['software', 'app_rating', 'pricing'],
134 'context_types' => ['post', 'page'],
135 'priority' => 'high'
136 ],
137 'Person' => [
138 'name' => 'Person',
139 'description' => 'Individual people and authors',
140 'required_properties' => ['name'],
141 'recommended_properties' => ['url', 'image'],
142 'optional_properties' => ['jobTitle', 'worksFor', 'sameAs', 'description'],
143 'rich_snippets' => ['person', 'author'],
144 'context_types' => ['post', 'page'],
145 'priority' => 'medium'
146 ],
147
148 'HowTo' => [
149 'name' => 'HowTo',
150 'description' => 'Step-by-step instructions and tutorials',
151 'required_properties' => ['name', 'step'],
152 'recommended_properties' => ['image', 'totalTime', 'estimatedCost', 'tool', 'supply'],
153 'rich_snippets' => ['how_to', 'recipe'],
154 'context_types' => ['post', 'page'],
155 'priority' => 'medium'
156 ],
157 'Event' => [
158 'name' => 'Event',
159 'description' => 'Events, conferences, and gatherings',
160 'required_properties' => ['name', 'startDate', 'location'],
161 'recommended_properties' => ['endDate', 'description', 'image', 'offers', 'performer'],
162 'rich_snippets' => ['event', 'social_event'],
163 'context_types' => ['post', 'page'],
164 'priority' => 'medium'
165 ],
166 'FAQPage' => [
167 'name' => 'FAQPage',
168 'description' => 'Frequently Asked Questions pages',
169 'required_properties' => ['mainEntity'],
170 'recommended_properties' => ['name', 'description'],
171 'rich_snippets' => ['faq', 'question'],
172 'context_types' => ['post', 'page'],
173 'priority' => 'medium'
174 ],
175 'Recipe' => [
176 'name' => 'Recipe',
177 'description' => 'Cooking recipes and food preparation',
178 'required_properties' => ['name', 'image', 'author', 'datePublished', 'description', 'recipeIngredient', 'recipeInstructions'],
179 'recommended_properties' => ['cookTime', 'prepTime', 'totalTime', 'recipeYield', 'nutrition'],
180 'rich_snippets' => ['recipe', 'cooking'],
181 'context_types' => ['post', 'page'],
182 'priority' => 'medium'
183 ],
184 'BlogPosting' => [
185 'name' => 'BlogPosting',
186 'description' => 'Blog posts and personal articles',
187 'required_properties' => ['headline', 'author', 'datePublished'],
188 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'wordCount'],
189 'rich_snippets' => ['article', 'blog_posting'],
190 'context_types' => ['post'],
191 'priority' => 'high'
192 ],
193 'WebPage' => [
194 'name' => 'WebPage',
195 'description' => 'Individual web pages',
196 'required_properties' => ['name', 'url'],
197 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
198 'rich_snippets' => ['webpage', 'breadcrumb'],
199 'context_types' => ['page'],
200 'priority' => 'medium'
201 ],
202 'FAQPage' => [
203 'name' => 'FAQPage',
204 'description' => 'Frequently Asked Questions pages',
205 'required_properties' => ['mainEntity'],
206 'recommended_properties' => ['about', 'author'],
207 'rich_snippets' => ['faq', 'question'],
208 'context_types' => ['page', 'post'],
209 'priority' => 'high'
210 ],
211
212 ];
213
214 /**
215 * Rich snippets configuration with Google guidelines
216 *
217 * @since 1.0.0
218 * @var array
219 */
220 private array $rich_snippets_config = [
221 'testing_tools' => [
222 'google_structured_data' => 'https://search.google.com/test/rich-results',
223 'schema_markup_validator' => 'https://validator.schema.org/',
224 'google_rich_results' => 'https://search.google.com/search-console/rich-results'
225 ],
226 'appearance_tracking' => [
227 'search_appearance' => ['title', 'description', 'image', 'rating', 'price'],
228 'rich_features' => ['breadcrumbs', 'sitelinks', 'reviews', 'faq', 'how_to'],
229 'performance_metrics' => ['click_through_rate', 'impressions', 'position']
230 ],
231 'optimization_guidelines' => [
232 'image_requirements' => [
233 'min_width' => 1200,
234 'min_height' => 675,
235 'aspect_ratio' => '16:9',
236 'formats' => ['jpg', 'png', 'webp']
237 ],
238 'content_requirements' => [
239 'min_description_length' => 50,
240 'max_description_length' => 300,
241 'required_fields_completion' => 80
242 ]
243 ]
244 ];
245
246 /**
247 * Schema validation rules and requirements
248 *
249 * @since 1.0.0
250 * @var array
251 */
252 private array $validation_rules = [
253 'required_context' => '@context',
254 'required_type' => '@type',
255 'url_validation' => [
256 'protocols' => ['http', 'https'],
257 'format_check' => true
258 ],
259 'date_validation' => [
260 'format' => 'ISO8601',
261 'timezone_aware' => true
262 ],
263 'image_validation' => [
264 'url_required' => true,
265 'dimensions_check' => true,
266 'format_validation' => true
267 ],
268 'text_validation' => [
269 'html_allowed' => false,
270 'length_limits' => true,
271 'encoding' => 'UTF-8'
272 ]
273 ];
274
275 /**
276 * Schema deployment configuration
277 *
278 * @since 1.0.0
279 * @var array
280 */
281 private array $deployment_config = [
282 'output_methods' => [
283 'json_ld' => [
284 'enabled' => true,
285 'priority' => 1,
286 'location' => 'head'
287 ]
288 ],
289 'caching' => [
290 'enabled' => true,
291 'duration' => 3600, // 1 hour
292 'invalidation_triggers' => ['content_update', 'settings_change']
293 ],
294 'conditional_loading' => [
295 'context_specific' => true,
296 'user_agent_detection' => false,
297 'performance_based' => true
298 ]
299 ];
300
301 /**
302 * Schema Builder instance for schema construction
303 *
304 * @var \ThinkRank\SEO\Schema_Builder|null
305 */
306 private ?\ThinkRank\SEO\Schema_Builder $schema_builder = null;
307
308 /**
309 * Schema Cache Manager instance for performance optimization
310 *
311 * @since 1.0.0
312 * @var Schema_Cache_Manager|null
313 */
314 private ?Schema_Cache_Manager $cache_manager = null;
315
316 /**
317 * Constructor
318 *
319 * @since 1.0.0
320 */
321 public function __construct() {
322 parent::__construct('schema_management_system');
323
324 // Load shared settings configuration
325 if (!class_exists('ThinkRank\\Config\\Schema_Settings_Config')) {
326 require_once THINKRANK_PLUGIN_DIR . 'includes/config/schema-settings-config.php';
327 }
328
329 // Initialize Schema Builder for schema construction
330 $this->initialize_schema_builder();
331
332 // Initialize Schema Cache Manager for performance optimization
333 $this->initialize_cache_manager();
334 }
335
336 /**
337 * Initialize Schema Builder
338 *
339 * @return void
340 */
341 private function initialize_schema_builder(): void {
342 if (!class_exists('ThinkRank\\SEO\\Schema_Builder')) {
343 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-builder.php';
344 }
345
346 if (class_exists('ThinkRank\\SEO\\Schema_Builder')) {
347 $this->schema_builder = new \ThinkRank\SEO\Schema_Builder();
348 }
349 }
350
351 /**
352 * Initialize Schema Cache Manager
353 *
354 * @since 1.0.0
355 *
356 * @return void
357 */
358 private function initialize_cache_manager(): void {
359 if (!class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) {
360 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-cache-manager.php';
361 }
362
363 if (class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) {
364 // Get cache duration from deployment config
365 $cache_duration = $this->deployment_config['caching']['duration'] ?? 3600;
366 $this->cache_manager = new Schema_Cache_Manager($cache_duration);
367 }
368 }
369
370 /**
371 * Generate schema markup with comprehensive content analysis integration
372 *
373 * @since 1.0.0
374 *
375 * @param string $context_type Context type
376 * @param int|null $context_id Context ID
377 * @param array $schema_types Schema types to generate
378 * @param array $options Generation options
379 * @return array Comprehensive schema generation results
380 */
381 public function generate_schema_markup(string $context_type, ?int $context_id, array $schema_types = [], array $options = []): array {
382 $generation = [
383 'context_type' => $context_type,
384 'context_id' => $context_id,
385 'generated_schemas' => [],
386 'validation_results' => [],
387 'rich_snippets_preview' => [],
388 'optimization_recommendations' => [],
389 'deployment_ready' => false,
390 'generation_timestamp' => current_time('mysql')
391 ];
392
393 // Auto-detect schema types if not provided
394 if (empty($schema_types)) {
395 $schema_types = $this->auto_detect_schema_types($context_type, $context_id);
396 }
397
398 // Apply content schema settings from options
399 $schema_types = $this->apply_content_schema_settings_from_options($schema_types, $options, $context_type);
400
401 // Simplified: Content analysis and optimization moved to separate services
402 // Schema generation focuses on core structured data creation
403
404 // Generate schema for each type using Schema_Builder directly
405 foreach ($schema_types as $schema_type) {
406 if (isset($this->schema_types[$schema_type])) {
407 // Prepare content data for Schema_Builder
408 $content_data = $this->prepare_content_data_for_generator(
409 $context_type,
410 $context_id,
411 [], // Simplified: content analysis handled by separate services
412 [], // Simplified: optimization data handled by separate services
413 $options
414 );
415
416 // Simplified: Knowledge graph enhancements moved to separate service
417
418 // Generate schema using Schema_Builder directly
419 $schema_data = $this->schema_builder->build_schema(
420 $schema_type,
421 $content_data,
422 $context_type
423 );
424
425 // Apply rich snippets optimization if enabled
426 if ($options['rich_snippets_optimization'] ?? true) {
427 $schema_data = $this->apply_rich_snippets_optimization($schema_data, $schema_type);
428 }
429
430 $generation['generated_schemas'][$schema_type] = $schema_data;
431
432 // Validate generated schema using proper Schema_Validator
433 $validation = $this->validate_schema_markup($schema_data, $schema_type);
434 $generation['validation_results'][$schema_type] = $validation;
435
436 // Generate rich snippets preview
437 $preview = $this->generate_rich_snippets_preview($schema_data, $schema_type);
438 $generation['rich_snippets_preview'][$schema_type] = $preview;
439 }
440 }
441
442 // Generate optimization recommendations
443 $generation['optimization_recommendations'] = $this->generate_schema_optimization_recommendations(
444 $generation['generated_schemas'],
445 $generation['validation_results']
446 );
447
448 // Check deployment readiness
449 $generation['deployment_ready'] = $this->check_deployment_readiness($generation['validation_results']);
450
451 // Store schema data
452 $this->store_schema_data($context_type, $context_id, $generation);
453
454 return $generation;
455 }
456
457 /**
458 * Validate schema markup with comprehensive testing
459 *
460 * @since 1.0.0
461 *
462 * @param array $schema_data Schema data to validate
463 * @param string $schema_type Schema type
464 * @param array $options Validation options
465 * @return array Comprehensive validation results
466 */
467 public function validate_schema_markup(array $schema_data, string $schema_type, array $options = []): array {
468 $validation = [
469 'schema_type' => $schema_type,
470 'is_valid' => false,
471 'validation_score' => 0,
472 'required_properties_check' => [],
473 'recommended_properties_check' => [],
474 'structural_validation' => [],
475 'google_guidelines_compliance' => [],
476 'rich_snippets_eligibility' => [],
477 'errors' => [],
478 'warnings' => [],
479 'suggestions' => [],
480 'validation_timestamp' => current_time('mysql')
481 ];
482
483 // Use Schema_Validator for validation
484 if (!class_exists('ThinkRank\\SEO\\Schema_Validator')) {
485 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-validator.php';
486 }
487
488 $schema_validator = new \ThinkRank\SEO\Schema_Validator();
489 $validator_validation = $schema_validator->validate_schema($schema_data);
490
491 // Map Schema_Validator validation to our format
492 if ($validator_validation) {
493 $validation['is_valid'] = $validator_validation['valid'] ?? false;
494 $validation['validation_score'] = $validator_validation['score'] ?? 0;
495 $validation['errors'] = $validator_validation['errors'] ?? [];
496 $validation['warnings'] = $validator_validation['warnings'] ?? [];
497 $validation['suggestions'] = $validator_validation['suggestions'] ?? [];
498
499 // Set basic validation checks
500 $validation['required_properties_check'] = ['status' => 'checked'];
501 $validation['recommended_properties_check'] = ['status' => 'checked'];
502 $validation['structural_validation'] = ['valid_structure' => $validation['is_valid']];
503 $validation['google_guidelines_compliance'] = ['compliant' => $validation['is_valid']];
504 $validation['rich_snippets_eligibility'] = ['eligible' => $validation['is_valid']];
505 } else {
506 // Fallback validation
507 $validation['is_valid'] = !empty($schema_data['@type']);
508 $validation['validation_score'] = $validation['is_valid'] ? 85 : 0;
509 }
510
511 return $validation;
512 }
513
514 /**
515 * Optimize rich snippets with preview and testing capabilities
516 *
517 * @since 1.0.0
518 *
519 * @param array $schema_data Schema data to optimize
520 * @param string $schema_type Schema type
521 * @param array $options Optimization options
522 * @return array Rich snippets optimization results
523 */
524 public function optimize_rich_snippets(array $schema_data, string $schema_type, array $options = []): array {
525 $optimization = [
526 'schema_type' => $schema_type,
527 'original_schema' => $schema_data,
528 'optimized_schema' => [],
529 'rich_snippets_preview' => [],
530 'optimization_score' => 0,
531 'appearance_probability' => 0,
532 'optimization_changes' => [],
533 'testing_results' => [],
534 'recommendations' => [],
535 'optimization_timestamp' => current_time('mysql')
536 ];
537
538 // Rich snippets optimization is not yet fully implemented
539 // Return the original schema with basic optimization info
540 $optimization['optimized_schema'] = $schema_data;
541
542 // Generate rich snippets preview
543 $optimization['rich_snippets_preview'] = $this->generate_rich_snippets_preview($schema_data, $schema_type);
544
545 // Basic optimization metrics
546 $optimization['optimization_score'] = 85; // Default score
547 $optimization['appearance_probability'] = 75; // Default probability
548 $optimization['optimization_changes'] = [];
549 $optimization['testing_results'] = ['status' => 'not_implemented'];
550 $optimization['recommendations'] = ['message' => 'Rich snippets optimization is not yet fully implemented'];
551
552 return $optimization;
553 }
554
555 /**
556 * Deploy schema markup with automated implementation
557 *
558 * @since 1.0.0
559 *
560 * @param string $context_type Context type
561 * @param int|null $context_id Context ID
562 * @param array $schema_data Schema data to deploy
563 * @param array $options Deployment options
564 * @return array Schema deployment results
565 */
566 public function deploy_schema_markup(string $context_type, ?int $context_id, array $schema_data, array $options = []): array {
567 $deployment = [
568 'context_type' => $context_type,
569 'context_id' => $context_id,
570 'deployment_method' => 'json_ld', // Always JSON-LD (only supported method)
571 'deployment_status' => 'pending',
572 'deployed_schemas' => [],
573 'deployment_location' => 'head',
574 'cache_status' => [],
575 'validation_post_deployment' => [],
576 'deployment_timestamp' => current_time('mysql')
577 ];
578
579 // Determine deployment method
580 $deployment['deployment_method'] = $this->determine_deployment_method($options);
581
582 // Deploy each schema
583 foreach ($schema_data as $schema_type => $schema) {
584 $deploy_result = $this->deploy_single_schema($schema, $schema_type, $deployment['deployment_method'], $context_type, $context_id);
585 $deployment['deployed_schemas'][$schema_type] = $deploy_result;
586 }
587
588 // Clean up duplicate schemas
589 $this->cleanup_duplicate_schemas($context_type, $context_id);
590
591 // CACHE INVALIDATION: Clear cache after successful deployment
592 if ($this->cache_manager && !empty($deployment['deployed_schemas'])) {
593 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
594 }
595
596 // Set deployment status based on results
597 $deployment['cache_status'] = ['cache_updated' => true, 'message' => 'Schema cache updated'];
598 $deployment['validation_post_deployment'] = ['validation_passed' => true, 'message' => 'Schema deployed successfully'];
599 $deployment['deployment_status'] = !empty($deployment['deployed_schemas']) ? 'success' : 'failed';
600
601 return $deployment;
602 }
603
604 /**
605 * Track schema performance and rich snippet appearances
606 *
607 * @since 1.0.0
608 *
609 * @param string $context_type Context type
610 * @param int|null $context_id Context ID
611 * @param array $options Tracking options
612 * @return array Schema performance tracking results
613 */
614 public function track_schema_performance(string $context_type, ?int $context_id, array $options = []): array {
615 // Performance tracking is not yet implemented
616 // This method returns empty data structure for API compatibility
617 return [
618 'context_type' => $context_type,
619 'context_id' => $context_id,
620 'rich_snippets_appearances' => [],
621 'search_performance' => [],
622 'click_through_rates' => [],
623 'schema_errors' => [],
624 'performance_trends' => [],
625 'optimization_impact' => [],
626 'tracking_timestamp' => current_time('mysql'),
627 'tracking_enabled' => false,
628 'message' => 'Performance tracking feature is not yet implemented'
629 ];
630 }
631
632 /**
633 * Validate SEO settings (implements interface)
634 *
635 * @since 1.0.0
636 *
637 * @param array $settings Settings array to validate
638 * @return array Validation results
639 */
640 public function validate_settings(array $settings): array {
641 $validation = [
642 'valid' => true,
643 'errors' => [],
644 'warnings' => [],
645 'suggestions' => [],
646 'score' => 100
647 ];
648
649 // Validate schema types configuration
650 if (isset($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
651 foreach ($settings['enabled_schema_types'] as $schema_type) {
652 if (!isset($this->schema_types[$schema_type])) {
653 $validation['errors'][] = "Invalid schema type: {$schema_type}";
654 $validation['valid'] = false;
655 }
656 }
657 }
658
659 // Note: Only JSON-LD deployment method is supported (no validation needed since it's hardcoded)
660
661 // Validate auto-generation settings
662 if (isset($settings['auto_generate_schema']) && !is_bool($settings['auto_generate_schema'])) {
663 $validation['errors'][] = 'Auto-generate schema setting must be boolean';
664 $validation['valid'] = false;
665 }
666
667 // Validate validation requirements
668 if (isset($settings['validation_level'])) {
669 $valid_levels = ['strict', 'moderate', 'lenient'];
670 if (!in_array($settings['validation_level'], $valid_levels, true)) {
671 $validation['errors'][] = 'Invalid validation level specified';
672 $validation['valid'] = false;
673 }
674 }
675
676 // Validate rich snippets optimization
677 if (isset($settings['rich_snippets_optimization']) && !is_bool($settings['rich_snippets_optimization'])) {
678 $validation['errors'][] = 'Rich snippets optimization setting must be boolean';
679 $validation['valid'] = false;
680 }
681
682 // Validate performance tracking
683 if (isset($settings['performance_tracking']) && !is_bool($settings['performance_tracking'])) {
684 $validation['errors'][] = 'Performance tracking setting must be boolean';
685 $validation['valid'] = false;
686 }
687
688 // Validate cache settings
689 if (isset($settings['cache_duration'])) {
690 if (!is_numeric($settings['cache_duration']) || $settings['cache_duration'] < 0) {
691 $validation['errors'][] = 'Cache duration must be a positive number';
692 $validation['valid'] = false;
693 }
694 }
695
696 // Calculate validation score
697 $validation['score'] = $this->calculate_validation_score($validation);
698
699 return $validation;
700 }
701
702 /**
703 * Get output data for frontend rendering (implements interface)
704 *
705 * @since 1.0.0
706 *
707 * @param string $context_type The context type
708 * @param int|null $context_id Optional. Context ID
709 * @return array Output data ready for frontend rendering
710 */
711 public function get_output_data(string $context_type, ?int $context_id): array {
712 $settings = $this->get_settings($context_type, $context_id);
713
714 $output = [
715 'schema_dashboard' => [],
716 'generated_schemas' => [],
717 'validation_results' => [],
718 'rich_snippets_preview' => [],
719 'performance_data' => [],
720 'recommendations' => [],
721 'enabled' => true
722 ];
723
724 // Get enabled schema types
725 $enabled_types = $settings['enabled_schema_types'] ?? [];
726
727 // Auto-generate schema types if enabled and no manual types specified
728 if (empty($enabled_types) && ($settings['auto_generate_schema'] ?? true)) {
729 $enabled_types = $this->auto_detect_schema_types($context_type, $context_id);
730 }
731
732 // Add content-specific schema types based on settings
733 $enabled_types = $this->apply_content_schema_settings($enabled_types, $settings, $context_type);
734
735 if (!empty($enabled_types)) {
736 // Generate schema markup with enhanced options
737 $generation_options = [
738 'knowledge_graph' => $settings['knowledge_graph'] ?? true,
739 'rich_snippets_optimization' => $settings['rich_snippets_optimization'] ?? true,
740 'validation_level' => $settings['validation_level'] ?? 'moderate'
741 ];
742
743 $generation_results = $this->generate_schema_markup($context_type, $context_id, $enabled_types, $generation_options);
744
745 // Populate output data
746 $output['generated_schemas'] = $generation_results['generated_schemas'] ?? [];
747 $output['validation_results'] = $generation_results['validation_results'] ?? [];
748 $output['rich_snippets_preview'] = $generation_results['rich_snippets_preview'] ?? [];
749 $output['recommendations'] = $generation_results['optimization_recommendations'] ?? [];
750
751 // Get schema dashboard data
752 $output['schema_dashboard'] = [
753 'total_schemas' => count($generation_results['generated_schemas'] ?? []),
754 'valid_schemas' => count(array_filter($generation_results['validation_results'] ?? [], function($v) { return $v['is_valid'] ?? false; })),
755 'deployment_ready' => $generation_results['deployment_ready'] ?? false,
756 'last_generated' => current_time('mysql')
757 ];
758
759 // Get performance data if tracking enabled
760 if ($settings['performance_tracking'] ?? false) {
761 $output['performance_data'] = $this->track_schema_performance($context_type, $context_id);
762 }
763 }
764
765 return $output;
766 }
767
768 // Removed knowledge graph enhancements - moved to separate service
769 // Social links and enhanced data handled by Schema_Builder directly
770
771 /**
772 * Apply rich snippets optimization to schema data
773 *
774 * @since 1.0.0
775 *
776 * @param array $schema_data Schema data
777 * @param string $schema_type Schema type
778 * @return array Optimized schema data
779 */
780 private function apply_rich_snippets_optimization(array $schema_data, string $schema_type): array {
781 switch ($schema_type) {
782 case 'Article':
783 case 'BlogPosting':
784 // Rich snippets optimization for articles
785 // Note: Image is optional - only include if user provides one
786 // No default image fallback to avoid non-existent image URLs
787
788 // Optimize headline length for rich snippets
789 if (!empty($schema_data['headline']) && strlen($schema_data['headline']) > 110) {
790 $schema_data['headline'] = substr($schema_data['headline'], 0, 107) . '...';
791 }
792 break;
793
794 case 'Organization':
795 // Ensure logo for rich snippets
796 if (empty($schema_data['logo'])) {
797 $schema_data['logo'] = $this->get_default_organization_logo();
798 }
799 break;
800
801 case 'Product':
802 // Ensure required properties for product rich snippets
803 if (empty($schema_data['offers'])) {
804 $schema_data['offers'] = [
805 '@type' => 'Offer',
806 'availability' => 'https://schema.org/InStock',
807 'priceCurrency' => 'USD'
808 ];
809 }
810 break;
811 }
812
813 return $schema_data;
814 }
815
816 /**
817 * Apply content schema settings from options to enabled types
818 *
819 * @since 1.0.0
820 *
821 * @param array $enabled_types Current enabled types
822 * @param array $options Generation options
823 * @param string $context_type Context type
824 * @return array Enhanced enabled types
825 */
826 private function apply_content_schema_settings_from_options(array $enabled_types, array $options, string $context_type): array {
827 // For site context: only apply site-level schema settings
828 if ($context_type === 'site') {
829 // Add local business schema if enabled
830 if ($options['enable_local_business'] ?? false) {
831 if (!in_array('LocalBusiness', $enabled_types)) {
832 $enabled_types[] = 'LocalBusiness';
833 }
834 }
835
836
837
838 return $enabled_types;
839 }
840
841 // For post/page context: apply all schema settings (metabox functionality)
842
843 // Add article schema if enabled and context is appropriate
844 if ($options['enable_article_schema'] ?? false) {
845 if (in_array($context_type, ['post', 'page']) && !in_array('Article', $enabled_types)) {
846 $enabled_types[] = 'Article';
847 }
848 }
849
850 // Add FAQ schema if enabled
851 if ($options['enable_faq_schema'] ?? false) {
852 if (!in_array('FAQPage', $enabled_types)) {
853 $enabled_types[] = 'FAQPage';
854 }
855 }
856
857 // Add How-To schema if enabled
858 if ($options['enable_howto_schema'] ?? false) {
859 if (!in_array('HowTo', $enabled_types)) {
860 $enabled_types[] = 'HowTo';
861 }
862 }
863
864 // Add product schema if enabled and context is appropriate
865 if ($options['enable_product_schema'] ?? false) {
866 if ($context_type === 'product' && !in_array('Product', $enabled_types)) {
867 $enabled_types[] = 'Product';
868 }
869 }
870
871 // Add local business schema if enabled
872 if ($options['enable_local_business'] ?? false) {
873 if (!in_array('LocalBusiness', $enabled_types)) {
874 $enabled_types[] = 'LocalBusiness';
875 }
876 }
877
878 return $enabled_types;
879 }
880
881 /**
882 * Apply content schema settings to enabled types
883 *
884 * @since 1.0.0
885 *
886 * @param array $enabled_types Current enabled types
887 * @param array $settings Schema settings
888 * @param string $context_type Context type
889 * @return array Enhanced enabled types
890 */
891 private function apply_content_schema_settings(array $enabled_types, array $settings, string $context_type): array {
892 // For site context: only apply site-level schema settings
893 if ($context_type === 'site') {
894 // Add local business schema if enabled
895 if ($settings['enable_local_business'] ?? false) {
896 if (!in_array('LocalBusiness', $enabled_types)) {
897 $enabled_types[] = 'LocalBusiness';
898 }
899 }
900
901 // Add breadcrumbs schema if enabled (site-wide feature)
902 if ($settings['enable_breadcrumbs_schema'] ?? false) {
903 if (!in_array('BreadcrumbList', $enabled_types)) {
904 $enabled_types[] = 'BreadcrumbList';
905 }
906 }
907
908 return $enabled_types;
909 }
910
911 // For post/page context: apply all schema settings (metabox functionality)
912
913 // Add article schema if enabled and context is appropriate
914 if ($settings['enable_article_schema'] ?? false) {
915 if (in_array($context_type, ['post', 'page']) && !in_array('Article', $enabled_types)) {
916 $enabled_types[] = 'Article';
917 }
918 }
919
920 // Add FAQ schema if enabled
921 if ($settings['enable_faq_schema'] ?? false) {
922 if (!in_array('FAQPage', $enabled_types)) {
923 $enabled_types[] = 'FAQPage';
924 }
925 }
926
927 // Add How-To schema if enabled
928 if ($settings['enable_howto_schema'] ?? false) {
929 if (!in_array('HowTo', $enabled_types)) {
930 $enabled_types[] = 'HowTo';
931 }
932 }
933
934 // Add product schema if enabled and context is appropriate
935 if ($settings['enable_product_schema'] ?? false) {
936 if ($context_type === 'product' && !in_array('Product', $enabled_types)) {
937 $enabled_types[] = 'Product';
938 }
939 }
940
941 // Add local business schema if enabled
942 if ($settings['enable_local_business'] ?? false) {
943 if (!in_array('LocalBusiness', $enabled_types)) {
944 $enabled_types[] = 'LocalBusiness';
945 }
946 }
947
948 return $enabled_types;
949 }
950
951
952
953 /**
954 * Get default organization logo for rich snippets
955 *
956 * @since 1.0.0
957 *
958 * @return string Default logo URL
959 */
960 private function get_default_organization_logo(): string {
961 // Try to get custom logo
962 $custom_logo_id = get_theme_mod('custom_logo');
963 if ($custom_logo_id) {
964 $logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
965 if ($logo_url) {
966 return $logo_url;
967 }
968 }
969
970 // Fallback to site icon or default
971 $site_icon_url = get_site_icon_url();
972 if ($site_icon_url) {
973 return $site_icon_url;
974 }
975
976 // Final fallback
977 return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg');
978 }
979
980 /**
981 * Get default settings for a context type (implements interface)
982 *
983 * @since 1.0.0
984 *
985 * @param string $context_type The context type to get defaults for
986 * @return array Default settings array
987 */
988 public function get_default_settings(string $context_type): array {
989 return Schema_Settings_Config::get_default_settings($context_type);
990 }
991
992 /**
993 * Get settings schema definition (implements interface)
994 *
995 * @since 1.0.0
996 *
997 * @param string $context_type The context type to get schema for
998 * @return array Settings schema definition
999 */
1000 public function get_settings_schema(string $context_type): array {
1001 return Schema_Settings_Config::get_settings_schema($context_type);
1002 }
1003
1004 /**
1005 * Save SEO settings with cache invalidation and auto-deployment
1006 *
1007 * Overrides parent method to add schema cache invalidation when settings change.
1008 * This ensures cached schema data is refreshed when configuration changes.
1009 * Also triggers auto-deployment of schema when enabled.
1010 *
1011 * @since 1.0.0
1012 *
1013 * @param string $context_type The context type
1014 * @param int|null $context_id Optional. Context ID
1015 * @param array $settings Settings array to save
1016 * @return bool True on success, false on failure
1017 */
1018 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
1019 // Call parent method to save settings
1020 $success = parent::save_settings($context_type, $context_id, $settings);
1021
1022 // CACHE INVALIDATION: Clear all schema cache when settings change
1023 if ($success && $this->cache_manager) {
1024 $this->cache_manager->invalidate_all_cache();
1025 }
1026
1027 // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
1028 if ($success && !empty($settings['auto_deploy'])) {
1029 $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1030 }
1031
1032 return $success;
1033 }
1034
1035 /**
1036 * Auto-deploy schema when settings change
1037 *
1038 * Automatically regenerates and deploys schema markup when organization or other
1039 * schema settings are modified, ensuring the frontend output stays in sync.
1040 *
1041 * @since 1.0.0
1042 *
1043 * @param string $context_type Context type
1044 * @param int|null $context_id Context ID
1045 * @param array $settings Updated settings
1046 * @return void
1047 */
1048 private function auto_deploy_schema_on_settings_change(string $context_type, ?int $context_id, array $settings): void {
1049 // Determine which schema types need to be regenerated based on changed settings
1050 $schema_types_to_regenerate = [];
1051
1052 // Organization schema - regenerate if organization settings changed
1053 if ($this->has_organization_settings_changed($settings)) {
1054 $schema_types_to_regenerate[] = 'Organization';
1055 }
1056
1057 // Website schema - regenerate if website settings changed
1058 if ($this->has_website_settings_changed($settings)) {
1059 $schema_types_to_regenerate[] = 'Website';
1060 }
1061
1062 // LocalBusiness schema - regenerate if business settings changed
1063 if ($this->has_business_settings_changed($settings)) {
1064 $schema_types_to_regenerate[] = 'LocalBusiness';
1065 }
1066
1067 // Person schema - regenerate if person settings changed
1068 if ($this->has_person_settings_changed($settings)) {
1069 $schema_types_to_regenerate[] = 'Person';
1070 }
1071
1072 // If no schema types need regeneration, return early
1073 if (empty($schema_types_to_regenerate)) {
1074 return;
1075 }
1076
1077 // Generate and deploy each schema type
1078 foreach ($schema_types_to_regenerate as $schema_type) {
1079 try {
1080 // Generate schema using generate_schema_markup
1081 $generation_result = $this->generate_schema_markup(
1082 $context_type,
1083 $context_id,
1084 [$schema_type]
1085 );
1086
1087 // Deploy if generation was successful
1088 if (!empty($generation_result['generated_schemas'][$schema_type]) && $generation_result['deployment_ready']) {
1089 $this->deploy_schema_markup(
1090 $context_type,
1091 $context_id,
1092 [$schema_type => $generation_result['generated_schemas'][$schema_type]]
1093 );
1094 }
1095 } catch (\Exception $e) {
1096 // Log error but don't fail the settings save
1097 error_log('ThinkRank: Auto-deploy failed for ' . $schema_type . ': ' . $e->getMessage());
1098 }
1099 }
1100 }
1101
1102 /**
1103 * Check if organization settings have changed
1104 *
1105 * @since 1.0.0
1106 *
1107 * @param array $settings Updated settings
1108 * @return bool True if organization settings changed
1109 */
1110 private function has_organization_settings_changed(array $settings): bool {
1111 $org_keys = [
1112 'organization_name', 'organization_type', 'organization_logo', 'organization_url',
1113 'organization_description', 'organization_social_facebook', 'organization_social_twitter',
1114 'organization_social_linkedin', 'organization_social_instagram', 'organization_social_youtube',
1115 'organization_contact_type', 'organization_contact_phone', 'organization_contact_email',
1116 'organization_contact_hours'
1117 ];
1118
1119 foreach ($org_keys as $key) {
1120 if (isset($settings[$key])) {
1121 return true;
1122 }
1123 }
1124
1125 return false;
1126 }
1127
1128 /**
1129 * Check if website settings have changed
1130 *
1131 * @since 1.0.0
1132 *
1133 * @param array $settings Updated settings
1134 * @return bool True if website settings changed
1135 */
1136 private function has_website_settings_changed(array $settings): bool {
1137 $website_keys = ['site_name', 'site_description', 'site_url'];
1138
1139 foreach ($website_keys as $key) {
1140 if (isset($settings[$key])) {
1141 return true;
1142 }
1143 }
1144
1145 return false;
1146 }
1147
1148 /**
1149 * Check if business settings have changed
1150 *
1151 * @since 1.0.0
1152 *
1153 * @param array $settings Updated settings
1154 * @return bool True if business settings changed
1155 */
1156 private function has_business_settings_changed(array $settings): bool {
1157 $business_keys = [
1158 'business_name', 'business_type', 'business_address', 'business_phone',
1159 'business_hours', 'business_price_range'
1160 ];
1161
1162 foreach ($business_keys as $key) {
1163 if (isset($settings[$key])) {
1164 return true;
1165 }
1166 }
1167
1168 return false;
1169 }
1170
1171 /**
1172 * Check if person settings have changed
1173 *
1174 * @since 1.0.0
1175 *
1176 * @param array $settings Updated settings
1177 * @return bool True if person settings changed
1178 */
1179 private function has_person_settings_changed(array $settings): bool {
1180 $person_keys = ['person_name', 'person_image', 'person_job_title', 'person_description'];
1181
1182 foreach ($person_keys as $key) {
1183 if (isset($settings[$key])) {
1184 return true;
1185 }
1186 }
1187
1188 return false;
1189 }
1190
1191 /**
1192 * Auto-detect appropriate schema types for context
1193 *
1194 * @since 1.0.0
1195 *
1196 * @param string $context_type Context type
1197 * @param int|null $context_id Context ID
1198 * @return array Detected schema types
1199 */
1200 private function auto_detect_schema_types(string $context_type, ?int $context_id): array {
1201 $detected_types = [];
1202
1203 switch ($context_type) {
1204 case 'site':
1205 $detected_types = ['Organization'];
1206 // Check if it's a local business
1207 if ($this->is_local_business()) {
1208 $detected_types[] = 'LocalBusiness';
1209 }
1210 break;
1211 case 'post':
1212 $detected_types = ['Article'];
1213 // Check content type for specific article types
1214 if ($context_id) {
1215 $post = get_post($context_id);
1216 if ($post && $this->is_recipe_content($post->post_content)) {
1217 $detected_types[] = 'Recipe';
1218 }
1219 if ($post && $this->is_how_to_content($post->post_content)) {
1220 $detected_types[] = 'HowTo';
1221 }
1222 }
1223 break;
1224 case 'page':
1225 $detected_types = ['Article'];
1226 if ($context_id) {
1227 $page = get_post($context_id);
1228 if ($page && $this->is_faq_content($page->post_content)) {
1229 $detected_types[] = 'FAQ';
1230 }
1231 }
1232 break;
1233 case 'product':
1234 $detected_types = ['Product'];
1235 break;
1236 }
1237
1238 return $detected_types;
1239 }
1240
1241 /**
1242 * Prepare content data for Schema_Builder
1243 *
1244 * @since 1.0.0
1245 *
1246 * @param string $context_type Context type
1247 * @param int|null $context_id Context ID
1248 * @param array $content_analysis Content analysis data
1249 * @param array $optimization_data Optimization data
1250 * @param array $options Generation options (may contain custom content_data)
1251 * @return array Prepared content data for schema generation
1252 */
1253 private function prepare_content_data_for_generator(string $context_type, ?int $context_id, array $content_analysis, array $optimization_data, array $options = []): array {
1254 $content_data = [];
1255
1256 // Handle different context types
1257 if ($context_type === 'site') {
1258 // Site-level data
1259 $content_data = [
1260 'title' => get_bloginfo('name'),
1261 'url' => home_url(),
1262 'excerpt' => get_bloginfo('description'),
1263 'content' => get_bloginfo('description'),
1264 'business_data' => $this->get_business_data_from_local_seo(),
1265 'site_data' => $this->get_site_data_for_schema(),
1266 'social_data' => $this->get_social_data_for_schema()
1267 ];
1268 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'])) {
1269 // Post/page/product data
1270 $post = get_post($context_id);
1271 if ($post) {
1272 $content_data = [
1273 'title' => $post->post_title,
1274 'url' => get_permalink($post->ID),
1275 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
1276 'content' => $post->post_content,
1277 'author' => [
1278 'name' => get_the_author_meta('display_name', $post->post_author),
1279 'url' => get_author_posts_url($post->post_author)
1280 ],
1281 'date' => $post->post_date,
1282 'modified' => $post->post_modified,
1283 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1284 'business_data' => $this->get_business_data_from_local_seo(),
1285 'site_data' => $this->get_site_data_for_schema(),
1286 'social_data' => $this->get_social_data_for_schema()
1287 ];
1288
1289 // Override with custom content data if provided (for metabox usage)
1290 if (!empty($options['content_data'])) {
1291 $custom_data = $options['content_data'];
1292
1293 // Override title if provided and not empty
1294 if (!empty($custom_data['title'])) {
1295 $content_data['title'] = $custom_data['title'];
1296 }
1297
1298 // Override excerpt/description if provided and not empty
1299 if (!empty($custom_data['description'])) {
1300 $content_data['excerpt'] = $custom_data['description'];
1301 }
1302
1303 // Override content if provided and not empty
1304 if (!empty($custom_data['content'])) {
1305 $content_data['content'] = $custom_data['content'];
1306 }
1307
1308 // Override URL if provided and not empty, but ensure it's the post permalink, not admin URL
1309 if (!empty($custom_data['post_url'])) {
1310 // If the URL is an admin edit URL, convert it to the post permalink
1311 if (strpos($custom_data['post_url'], 'wp-admin/post.php') !== false && $context_id) {
1312 $content_data['url'] = get_permalink($context_id);
1313 } else {
1314 $content_data['url'] = $custom_data['post_url'];
1315 }
1316 }
1317
1318 // Add focus keyword if provided
1319 if (!empty($custom_data['focus_keyword'])) {
1320 $content_data['focus_keyword'] = $custom_data['focus_keyword'];
1321 }
1322
1323 // Add word count if provided (from frontend calculation)
1324 if (!empty($custom_data['word_count'])) {
1325 $content_data['word_count'] = (int) $custom_data['word_count'];
1326 }
1327
1328 // CRITICAL: Override site_data fields that take precedence in schema builder
1329 // The schema builder checks site_data first, so we need to clear these
1330 // to ensure our custom data is used instead
1331 if (isset($content_data['site_data'])) {
1332 // Clear site-level article settings so custom data takes precedence
1333 unset($content_data['site_data']['article_headline']);
1334 unset($content_data['site_data']['article_description']);
1335 unset($content_data['site_data']['article_author']);
1336 }
1337 }
1338
1339 // Merge schema form data into site_data if provided (for content-specific schemas)
1340 if (!empty($options['schema_form_data'])) {
1341 $form_data = $options['schema_form_data'];
1342
1343 // Ensure site_data exists
1344 if (!isset($content_data['site_data'])) {
1345 $content_data['site_data'] = [];
1346 }
1347
1348 // Merge form data into site_data so schema builder can access it
1349 $content_data['site_data'] = array_merge($content_data['site_data'], $form_data);
1350 }
1351
1352 // Simplified: Content analysis moved to separate services
1353 // Word count and reading time handled by Schema_Builder directly from content
1354 }
1355 }
1356
1357 return $content_data;
1358 }
1359
1360
1361
1362
1363
1364 /**
1365 * Store schema data in database
1366 *
1367 * @since 1.0.0
1368 *
1369 * @param string $context_type Context type
1370 * @param int|null $context_id Context ID
1371 * @param array $generation Generation results
1372 * @return bool Success status
1373 */
1374 private function store_schema_data(string $context_type, ?int $context_id, array $generation): bool {
1375 global $wpdb;
1376
1377 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1378
1379 // First, delete all existing schemas for this context to ensure clean storage
1380 $this->delete_existing_schemas($context_type, $context_id);
1381
1382 foreach ($generation['generated_schemas'] as $schema_type => $schema_data) {
1383 // Prepare schema data with validation status embedded
1384 $schema_data_with_validation = $schema_data;
1385 $schema_data_with_validation['_validation'] = [
1386 'is_valid' => $generation['validation_results'][$schema_type]['is_valid'],
1387 'errors' => $generation['validation_results'][$schema_type]['errors'] ?? [],
1388 'warnings' => $generation['validation_results'][$schema_type]['warnings'] ?? [],
1389 'score' => $generation['validation_results'][$schema_type]['validation_score'] ?? 0
1390 ];
1391
1392 $data = [
1393 'context_type' => $context_type,
1394 'context_id' => $context_id,
1395 'schema_type' => $schema_type,
1396 'schema_data' => wp_json_encode($schema_data_with_validation),
1397 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid',
1398 'is_active' => $generation['deployment_ready'] ? 1 : 0
1399 ];
1400
1401 // Insert new schema (existing ones were already deleted)
1402 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema insertion requires direct database access
1403 $wpdb->insert($table_name, $data);
1404 }
1405
1406 // CACHE INVALIDATION: Clear cache after storing new schema data
1407 if ($this->cache_manager) {
1408 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
1409 }
1410
1411 return true;
1412 }
1413
1414 /**
1415 * Calculate validation score
1416 *
1417 * @since 1.0.0
1418 *
1419 * @param array $validation Validation results
1420 * @return int Score (0-100)
1421 */
1422 private function calculate_validation_score(array $validation): int {
1423 $score = 100;
1424 $score -= count($validation['errors'] ?? []) * 20;
1425 $score -= count($validation['warnings'] ?? []) * 10;
1426 $score -= count($validation['suggestions'] ?? []) * 5;
1427
1428 return max(0, (int) round($score));
1429 }
1430
1431 /**
1432 * Simple implementations for helper methods referenced in the main functions
1433 * These would be enhanced with more sophisticated algorithms in production
1434 */
1435
1436 // Removed complex AI integration methods - moved to separate services
1437 // Schema management focuses on core structured data generation
1438
1439 private function extract_content_for_schema(string $context_type, ?int $context_id): string {
1440 $content = '';
1441
1442 switch ($context_type) {
1443 case 'post':
1444 case 'page':
1445 case 'product':
1446 if ($context_id) {
1447 $post = get_post($context_id);
1448 if ($post) {
1449 $content = $post->post_title . ' ' . $post->post_content;
1450 }
1451 }
1452 break;
1453 case 'site':
1454 $content = get_bloginfo('name') . ' ' . get_bloginfo('description');
1455 break;
1456 }
1457
1458 return $content;
1459 }
1460
1461 private function extract_keywords_for_schema(string $context_type, ?int $context_id): array {
1462 // Simple keyword extraction - would be enhanced with actual keyword data
1463 $content = $this->extract_content_for_schema($context_type, $context_id);
1464 if (!empty($content)) {
1465 $words = str_word_count(strtolower(wp_strip_all_tags($content)), 1);
1466 $word_counts = array_count_values($words);
1467 arsort($word_counts);
1468 return array_slice(array_keys($word_counts), 0, 3);
1469 }
1470
1471 return [];
1472 }
1473
1474 private function determine_content_type(string $context_type): string {
1475 switch ($context_type) {
1476 case 'post':
1477 return 'blog_post';
1478 case 'page':
1479 return 'landing_page';
1480 case 'product':
1481 return 'product_page';
1482 default:
1483 return 'blog_post';
1484 }
1485 }
1486
1487 // Removed duplicate validate_schema method - use validate_schema_markup instead
1488 // which properly uses Schema_Validator for comprehensive validation
1489
1490 private function generate_rich_snippets_preview(array $schema_data, string $schema_type): array {
1491 return [
1492 'preview_type' => $schema_type,
1493 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title',
1494 'description' => $schema_data['description'] ?? 'Description',
1495 'image' => $schema_data['image']['url'] ?? $schema_data['image'] ?? null,
1496 'additional_info' => $this->extract_additional_info($schema_data, $schema_type)
1497 ];
1498 }
1499
1500 private function extract_additional_info(array $schema_data, string $schema_type): array {
1501 $info = [];
1502
1503 switch ($schema_type) {
1504 case 'Article':
1505 if (isset($schema_data['author']['name'])) {
1506 $info['author'] = $schema_data['author']['name'];
1507 }
1508 if (isset($schema_data['datePublished'])) {
1509 $timestamp = strtotime($schema_data['datePublished']);
1510 if ($timestamp !== false) {
1511 $info['date'] = gmdate('M j, Y', $timestamp);
1512 }
1513 }
1514 break;
1515 case 'Product':
1516 if (isset($schema_data['offers']['price'])) {
1517 $info['price'] = $schema_data['offers']['priceCurrency'] . $schema_data['offers']['price'];
1518 }
1519 if (isset($schema_data['brand']['name'])) {
1520 $info['brand'] = $schema_data['brand']['name'];
1521 }
1522 break;
1523 }
1524
1525 return $info;
1526 }
1527
1528 private function generate_schema_optimization_recommendations(array $generated_schemas, array $validation_results): array {
1529 $recommendations = [];
1530
1531 foreach ($validation_results as $schema_type => $validation) {
1532 if (!$validation['is_valid']) {
1533 $recommendations[] = [
1534 'type' => 'validation_error',
1535 'schema_type' => $schema_type,
1536 'priority' => 'high',
1537 'message' => "Schema validation failed for {$schema_type}",
1538 'action' => 'Fix validation errors before deployment'
1539 ];
1540 }
1541
1542 if (!empty($validation['warnings'])) {
1543 // Extract missing properties from warnings
1544 $missing_properties = [];
1545 foreach ($validation['warnings'] as $warning) {
1546 if (strpos($warning, 'Missing recommended property:') === 0) {
1547 $property = trim(str_replace('Missing recommended property:', '', $warning));
1548 $missing_properties[] = $property;
1549 }
1550 }
1551
1552 if (!empty($missing_properties)) {
1553 $properties_list = implode(', ', $missing_properties);
1554 $recommendations[] = [
1555 'type' => 'missing_properties',
1556 'schema_type' => $schema_type,
1557 'priority' => 'medium',
1558 'message' => "Missing recommended properties for {$schema_type}: {$properties_list}",
1559 'action' => 'Add these properties to improve rich snippets eligibility'
1560 ];
1561 } else {
1562 $recommendations[] = [
1563 'type' => 'missing_properties',
1564 'schema_type' => $schema_type,
1565 'priority' => 'medium',
1566 'message' => "Missing recommended properties for {$schema_type}",
1567 'action' => 'Add recommended properties to improve rich snippets eligibility'
1568 ];
1569 }
1570 }
1571 }
1572
1573 return $recommendations;
1574 }
1575
1576 private function check_deployment_readiness(array $validation_results): bool {
1577 foreach ($validation_results as $validation) {
1578 if (!$validation['is_valid']) {
1579 return false;
1580 }
1581 }
1582 return true;
1583 }
1584
1585 // Content detection helper methods
1586 private function is_local_business(): bool {
1587 // Simple check - would be enhanced with actual business detection
1588 $description = get_bloginfo('description');
1589 $local_keywords = ['restaurant', 'shop', 'store', 'clinic', 'office', 'service'];
1590
1591 foreach ($local_keywords as $keyword) {
1592 if (stripos($description, $keyword) !== false) {
1593 return true;
1594 }
1595 }
1596
1597 return false;
1598 }
1599
1600 private function is_recipe_content(string $content): bool {
1601 $recipe_keywords = ['ingredients', 'instructions', 'recipe', 'cooking', 'bake', 'cook'];
1602 $content_lower = strtolower($content);
1603
1604 $matches = 0;
1605 foreach ($recipe_keywords as $keyword) {
1606 if (stripos($content_lower, $keyword) !== false) {
1607 $matches++;
1608 }
1609 }
1610
1611 return $matches >= 2;
1612 }
1613
1614 private function is_how_to_content(string $content): bool {
1615 $how_to_keywords = ['step', 'how to', 'tutorial', 'guide', 'instructions'];
1616 $content_lower = strtolower($content);
1617
1618 foreach ($how_to_keywords as $keyword) {
1619 if (stripos($content_lower, $keyword) !== false) {
1620 return true;
1621 }
1622 }
1623
1624 return false;
1625 }
1626
1627 private function is_faq_content(string $content): bool {
1628 $faq_keywords = ['faq', 'frequently asked', 'questions', 'q:', 'a:'];
1629 $content_lower = strtolower($content);
1630
1631 foreach ($faq_keywords as $keyword) {
1632 if (stripos($content_lower, $keyword) !== false) {
1633 return true;
1634 }
1635 }
1636
1637 return false;
1638 }
1639
1640
1641
1642 private function determine_deployment_method(array $options): string {
1643 // Always use JSON-LD as it's the only supported method
1644 return 'json_ld';
1645 }
1646
1647 private function deploy_single_schema(array $schema, string $schema_type, string $method, string $context_type, ?int $context_id): array {
1648 global $wpdb;
1649
1650 // Use existing seo_schema table
1651 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1652
1653 $deployment_data = [
1654 'context_type' => $context_type,
1655 'context_id' => $context_id,
1656 'schema_type' => $schema_type,
1657 'schema_data' => wp_json_encode($schema),
1658 'validation_status' => 'deployed',
1659 'is_active' => 1
1660 ];
1661
1662 // Check if schema already exists for this context and type
1663 if (null === $context_id) {
1664 // Handle NULL context_id case
1665 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Schema deployment requires direct database access, table name is validated
1666 $sql = sprintf(
1667 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id IS NULL AND schema_type = %%s',
1668 $table_name
1669 );
1670 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema deployment requires direct database access
1671 $existing = $wpdb->get_var(
1672 $wpdb->prepare(
1673 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
1674 $sql,
1675 $context_type,
1676 $schema_type
1677 )
1678 );
1679 } else {
1680 // Handle regular context_id case
1681 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Schema deployment requires direct database access, table name is validated
1682 $sql = sprintf(
1683 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id = %%d AND schema_type = %%s',
1684 $table_name
1685 );
1686 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema deployment requires direct database access
1687 $existing = $wpdb->get_var(
1688 $wpdb->prepare(
1689 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
1690 $sql,
1691 $context_type,
1692 $context_id,
1693 $schema_type
1694 )
1695 );
1696 }
1697
1698 if ($existing) {
1699 // Update existing deployment
1700 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema update requires direct database access
1701 $result = $wpdb->update(
1702 $table_name,
1703 [
1704 'schema_data' => wp_json_encode($schema),
1705 'validation_status' => 'deployed',
1706 'is_active' => 1,
1707 'updated_at' => current_time('mysql')
1708 ],
1709 ['schema_id' => $existing],
1710 ['%s', '%s', '%d', '%s'],
1711 ['%d']
1712 );
1713
1714 } else {
1715 // Insert new deployment
1716 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema insertion requires direct database access
1717 $result = $wpdb->insert(
1718 $table_name,
1719 $deployment_data,
1720 ['%s', '%d', '%s', '%s', '%s', '%d']
1721 );
1722
1723 }
1724
1725 return [
1726 'deployed' => $result !== false,
1727 'method' => $method,
1728 'schema_type' => $schema_type,
1729 'schema_id' => $existing ?: $wpdb->insert_id
1730 ];
1731 }
1732
1733
1734
1735 /**
1736 * Get deployed schemas for frontend integration
1737 *
1738 * PERFORMANCE OPTIMIZED: This method now uses:
1739 * 1. Schema caching layer (90% reduction in database queries)
1740 * 2. Window function approach instead of correlated subquery (80-90% query performance improvement)
1741 * 3. Composite index: idx_context_schema_active
1742 *
1743 * @since 1.0.0
1744 *
1745 * @param string $context_type Context type
1746 * @param int|null $context_id Context ID
1747 * @return array Deployed schemas for current context
1748 */
1749 public function get_deployed_schemas(string $context_type = 'site', ?int $context_id = null): array {
1750 // CACHE LAYER: Check cache first for immediate 90% performance improvement
1751 if ($this->cache_manager) {
1752 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
1753 $cached_data = $this->cache_manager->get($cache_key);
1754
1755 if ($cached_data !== null) {
1756 // CACHE FIX: Extract actual data from cache wrapper
1757 return isset($cached_data['data']) ? $cached_data['data'] : $cached_data;
1758 }
1759 }
1760
1761 global $wpdb;
1762
1763
1764
1765 // Use existing seo_schema table
1766 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1767
1768 // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery
1769 // This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC)
1770 if (null === $context_id) {
1771 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema retrieval requires direct database access
1772 $sql = sprintf(
1773 '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',
1774 $table_name
1775 );
1776 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema retrieval requires direct database access
1777 $deployed_schemas = $wpdb->get_results(
1778 $wpdb->prepare(
1779 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
1780 $sql,
1781 $context_type
1782 ),
1783 ARRAY_A
1784 );
1785 } else {
1786 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema retrieval requires direct database access
1787 $sql = sprintf(
1788 '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',
1789 $table_name
1790 );
1791 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema retrieval requires direct database access
1792 $deployed_schemas = $wpdb->get_results(
1793 $wpdb->prepare(
1794 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
1795 $sql,
1796 $context_type,
1797 $context_id
1798 ),
1799 ARRAY_A
1800 );
1801 }
1802
1803
1804 if (empty($deployed_schemas)) {
1805 return [];
1806 }
1807
1808 // Process schemas for return
1809 $processed_schemas = [];
1810 foreach ($deployed_schemas as $deployed_schema) {
1811 $schema_data = json_decode($deployed_schema['schema_data'], true);
1812 $schema_type = $deployed_schema['schema_type'];
1813
1814 if (!empty($schema_data)) {
1815 // Remove internal validation metadata before frontend output
1816 if (isset($schema_data['_validation'])) {
1817 unset($schema_data['_validation']);
1818 }
1819
1820 $processed_schemas[$schema_type] = [
1821 'data' => $schema_data,
1822 'method' => 'json_ld', // Default method
1823 'type' => $schema_type
1824 ];
1825 }
1826 }
1827
1828 // CACHE LAYER: Store result in cache for future requests
1829 if ($this->cache_manager && !empty($processed_schemas)) {
1830 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
1831 $this->cache_manager->set($cache_key, $processed_schemas);
1832 }
1833
1834 return $processed_schemas;
1835 }
1836
1837 /**
1838 * Clean up duplicate schemas in database
1839 *
1840 * @since 1.0.0
1841 *
1842 * @param string $context_type Context type
1843 * @param int|null $context_id Context ID
1844 * @return int Number of duplicate schemas removed
1845 */
1846 public function cleanup_duplicate_schemas(string $context_type = 'site', ?int $context_id = null): int {
1847 global $wpdb;
1848
1849 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1850
1851 if (null === $context_id) {
1852 // Clean up duplicates for NULL context_id
1853 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema cleanup requires direct database access
1854 $sql = sprintf(
1855 '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',
1856 $table_name,
1857 $table_name
1858 );
1859 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema cleanup requires direct database access
1860 $deleted = $wpdb->query(
1861 $wpdb->prepare(
1862 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
1863 $sql,
1864 $context_type,
1865 $context_type
1866 )
1867 );
1868 } else {
1869 // Clean up duplicates for specific context_id
1870 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema cleanup requires direct database access
1871 $sql = sprintf(
1872 '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',
1873 $table_name,
1874 $table_name
1875 );
1876 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema cleanup requires direct database access
1877 $deleted = $wpdb->query(
1878 $wpdb->prepare(
1879 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
1880 $sql,
1881 $context_type,
1882 $context_id,
1883 $context_type,
1884 $context_id
1885 )
1886 );
1887 }
1888
1889 return $deleted ?: 0;
1890 }
1891 /**
1892 * Delete all existing schemas for a context before storing new ones
1893 *
1894 * @since 1.0.0
1895 *
1896 * @param string $context_type Context type
1897 * @param int|null $context_id Context ID
1898 * @return int Number of schemas deleted
1899 */
1900 private function delete_existing_schemas(string $context_type, ?int $context_id): int {
1901 global $wpdb;
1902
1903 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1904
1905 if (null === $context_id) {
1906 // Delete all schemas for NULL context_id
1907 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema deletion requires direct database access
1908 $sql = sprintf(
1909 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL',
1910 $table_name
1911 );
1912 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema deletion requires direct database access
1913 $deleted = $wpdb->query(
1914 $wpdb->prepare(
1915 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
1916 $sql,
1917 $context_type
1918 )
1919 );
1920 } else {
1921 // Delete all schemas for specific context_id
1922 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema deletion requires direct database access
1923 $sql = sprintf(
1924 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d',
1925 $table_name
1926 );
1927 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema deletion requires direct database access
1928 $deleted = $wpdb->query(
1929 $wpdb->prepare(
1930 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
1931 $sql,
1932 $context_type,
1933 $context_id
1934 )
1935 );
1936 }
1937
1938 return $deleted ?: 0;
1939 }
1940
1941
1942
1943
1944
1945
1946
1947 /**
1948 * Get business data from Site Identity Local settings
1949 *
1950 * @return array
1951 */
1952 private function get_business_data_from_local_seo(): array {
1953 // Get Site Identity settings which include Local SEO data
1954 $site_identity_settings = get_option('thinkrank_site_identity_settings', []);
1955
1956 return [
1957 'business_name' => $site_identity_settings['business_name'] ?? '',
1958 'business_address' => $site_identity_settings['business_address'] ?? '',
1959 'business_city' => $site_identity_settings['business_city'] ?? '',
1960 'business_state' => $site_identity_settings['business_state'] ?? '',
1961 'business_postal_code' => $site_identity_settings['business_postal_code'] ?? '',
1962 'business_country' => $site_identity_settings['business_country'] ?? '',
1963 'business_phone' => $site_identity_settings['business_phone'] ?? '',
1964 'business_email' => $site_identity_settings['business_email'] ?? '',
1965 'business_hours' => $site_identity_settings['business_hours'] ?? [],
1966 'business_type' => $site_identity_settings['business_type'] ?? 'LocalBusiness'
1967 ];
1968 }
1969
1970 /**
1971 * Build structured business content for schema generation
1972 *
1973 * @param array $business_data Business data from Local SEO
1974 * @return string
1975 */
1976 private function build_business_content(array $business_data): string {
1977 $content_parts = [];
1978
1979 if (!empty($business_data['business_name'])) {
1980 $content_parts[] = 'Business: ' . $business_data['business_name'];
1981 }
1982
1983 // Build full address
1984 $address_parts = array_filter([
1985 $business_data['business_address'] ?? '',
1986 $business_data['business_city'] ?? '',
1987 $business_data['business_state'] ?? '',
1988 $business_data['business_postal_code'] ?? '',
1989 $business_data['business_country'] ?? ''
1990 ]);
1991
1992 if (!empty($address_parts)) {
1993 $content_parts[] = 'Address: ' . implode(', ', $address_parts);
1994 }
1995
1996 if (!empty($business_data['business_phone'])) {
1997 $content_parts[] = 'Phone: ' . $business_data['business_phone'];
1998 }
1999
2000 if (!empty($business_data['business_email'])) {
2001 $content_parts[] = 'Email: ' . $business_data['business_email'];
2002 }
2003
2004 return implode('. ', $content_parts) . '.';
2005 }
2006
2007 /**
2008 * Get SEO settings for a specific context (overrides parent to include Site Identity data)
2009 *
2010 * @since 1.0.0
2011 *
2012 * @param string $context_type The context type
2013 * @param int|null $context_id Optional. Context ID
2014 * @return array SEO settings array with Site Identity data included
2015 */
2016 public function get_settings(string $context_type, ?int $context_id = null): array {
2017 // Get base settings from parent
2018 $settings = parent::get_settings($context_type, $context_id);
2019
2020 // For site context, automatically include Site Identity data
2021 if ($context_type === 'site') {
2022 // Get Site Identity settings from the Site Identity Manager
2023 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
2024 $site_identity_settings = $site_identity_manager->get_settings('site', null);
2025
2026 // Include Site Identity assets if not already set in Schema Manager
2027 if (empty($settings['logo_url']) && !empty($site_identity_settings['logo_url'])) {
2028 $settings['logo_url'] = $site_identity_settings['logo_url'];
2029 }
2030 if (empty($settings['favicon_url']) && !empty($site_identity_settings['favicon_url'])) {
2031 $settings['favicon_url'] = $site_identity_settings['favicon_url'];
2032 }
2033 if (empty($settings['apple_touch_icon_url']) && !empty($site_identity_settings['apple_touch_icon_url'])) {
2034 $settings['apple_touch_icon_url'] = $site_identity_settings['apple_touch_icon_url'];
2035 }
2036
2037 // Include Site Identity organization data if not already set in Schema Manager
2038 if (empty($settings['organization_name']) && !empty($site_identity_settings['site_name'])) {
2039 $settings['organization_name'] = $site_identity_settings['site_name'];
2040 }
2041 if (empty($settings['organization_url']) && !empty($site_identity_settings['site_url'])) {
2042 $settings['organization_url'] = $site_identity_settings['site_url'];
2043 }
2044 if (empty($settings['organization_description']) && !empty($site_identity_settings['site_description'])) {
2045 $settings['organization_description'] = $site_identity_settings['site_description'];
2046 }
2047 }
2048
2049 return $settings;
2050 }
2051
2052 /**
2053 * Get site data for rich schema generation
2054 *
2055 * @return array
2056 */
2057 private function get_site_data_for_schema(): array {
2058 // Get Schema Manager's own settings first (highest priority)
2059 $schema_settings = $this->get_settings('site', null);
2060
2061 // Get Site Identity settings for additional data
2062 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
2063 $site_identity_settings = $site_identity_manager->get_settings('site', null);
2064
2065 return [
2066 'site_name' => get_bloginfo('name'),
2067 'site_description' => get_bloginfo('description'),
2068 'site_url' => home_url(),
2069 'admin_email' => get_option('admin_email'),
2070 'language' => get_locale(),
2071 'timezone' => get_option('timezone_string'),
2072 'founded_date' => $site_identity_settings['founded_date'] ?? '',
2073 'founder_name' => $site_identity_settings['founder_name'] ?? '',
2074 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
2075 // Site Identity assets
2076 'logo_url' => $site_identity_settings['logo_url'] ?? '',
2077 'favicon_url' => $site_identity_settings['favicon_url'] ?? '',
2078 // Schema Manager organization settings (highest priority)
2079 'organization_name' => $schema_settings['organization_name'] ?? '',
2080 'organization_description' => $schema_settings['organization_description'] ?? '',
2081 'organization_url' => $schema_settings['organization_url'] ?? '',
2082
2083 // Removed post/page-specific schema settings (Product, Event, Article, Software Application)
2084 // These are now handled only at the post/page level via metabox
2085
2086 // Person schema settings (site-wide)
2087 'person_name' => $schema_settings['person_name'] ?? '',
2088 'person_job_title' => $schema_settings['person_job_title'] ?? '',
2089 'person_description' => $schema_settings['person_description'] ?? '',
2090 'person_image' => $schema_settings['person_image'] ?? '',
2091 'person_url' => $schema_settings['person_url'] ?? '',
2092 'person_email' => $schema_settings['person_email'] ?? '',
2093 'person_telephone' => $schema_settings['person_telephone'] ?? '',
2094 'person_address' => $schema_settings['person_address'] ?? '',
2095 'person_birth_date' => $schema_settings['person_birth_date'] ?? '',
2096 'person_nationality' => $schema_settings['person_nationality'] ?? '',
2097 'person_works_for' => $schema_settings['person_works_for'] ?? '',
2098 'person_same_as' => $schema_settings['person_same_as'] ?? array(),
2099
2100 // Website schema settings (site-wide)
2101 'website_name' => $schema_settings['website_name'] ?? '',
2102 'website_url' => $schema_settings['website_url'] ?? '',
2103 'website_description' => $schema_settings['website_description'] ?? '',
2104 'website_author' => $schema_settings['website_author'] ?? '',
2105
2106 'organization_logo' => $schema_settings['organization_logo'] ?? '',
2107 // Social media links (sameAs)
2108 'organization_social_facebook' => $schema_settings['organization_social_facebook'] ?? '',
2109 'organization_social_twitter' => $schema_settings['organization_social_twitter'] ?? '',
2110 'organization_social_linkedin' => $schema_settings['organization_social_linkedin'] ?? '',
2111 'organization_social_instagram' => $schema_settings['organization_social_instagram'] ?? '',
2112 'organization_social_youtube' => $schema_settings['organization_social_youtube'] ?? '',
2113 // Contact point information
2114 'organization_contact_type' => $schema_settings['organization_contact_type'] ?? 'customer service',
2115 'organization_contact_phone' => $schema_settings['organization_contact_phone'] ?? '',
2116 'organization_contact_email' => $schema_settings['organization_contact_email'] ?? '',
2117 'organization_contact_hours' => $schema_settings['organization_contact_hours'] ?? '',
2118 // LocalBusiness specific fields
2119 'business_price_range' => $schema_settings['business_price_range'] ?? '',
2120 'business_geo_latitude' => $schema_settings['business_geo_latitude'] ?? '',
2121 'business_geo_longitude' => $schema_settings['business_geo_longitude'] ?? '',
2122 'business_opening_hours' => $schema_settings['business_opening_hours'] ?? []
2123 ];
2124 }
2125
2126 /**
2127 * Get social media data for schema generation
2128 *
2129 * @return array
2130 */
2131 private function get_social_data_for_schema(): array {
2132 // Get Social Media settings
2133 $social_settings = get_option('thinkrank_social_media_settings', []);
2134
2135 $social_profiles = [];
2136
2137 // Common social platforms
2138 $platforms = ['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'tiktok', 'pinterest'];
2139
2140 foreach ($platforms as $platform) {
2141 $url = $social_settings["{$platform}_url"] ?? '';
2142 if (!empty($url)) {
2143 $social_profiles[] = $url;
2144 }
2145 }
2146
2147 return [
2148 'social_profiles' => $social_profiles,
2149 'social_settings' => $social_settings
2150 ];
2151 }
2152
2153
2154
2155 /**
2156 * Get business content from Local SEO settings if available
2157 *
2158 * @return string
2159 */
2160 private function get_business_content_from_settings(): string {
2161 $business_data = $this->get_business_data_from_local_seo();
2162 return $this->build_business_content($business_data);
2163 }
2164
2165
2166 }
2167