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

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