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

2,095 lines 86.0 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 $cache_invalidated = false;
589 if ($this->cache_manager && !empty($deployment['deployed_schemas'])) {
590 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
591 $cache_invalidated = true;
592 }
593
594 $deployment['cache_status'] = $cache_invalidated
595 ? ['cache_updated' => true, 'message' => 'Schema cache invalidated']
596 : ['cache_updated' => false, 'message' => 'No schema cache to invalidate'];
597
598 // Post-deployment verification: read back through the same accessor the
599 // front end uses, so a row that was written but is not retrievable (wrong
600 // context, inactive, stale cache) is reported as a failure instead of
601 // being assumed successful.
602 $deployment['validation_post_deployment'] = $this->verify_deployment(
603 $context_type,
604 $context_id,
605 array_keys($deployment['deployed_schemas'])
606 );
607
608 $writes_ok = !empty($deployment['deployed_schemas']);
609 foreach ($deployment['deployed_schemas'] as $deploy_result) {
610 if (empty($deploy_result['deployed'])) {
611 $writes_ok = false;
612 break;
613 }
614 }
615
616 $deployment['deployment_status'] =
617 ($writes_ok && !empty($deployment['validation_post_deployment']['validation_passed']))
618 ? 'success'
619 : 'failed';
620
621 return $deployment;
622 }
623
624 /**
625 * Verify deployed schema is retrievable after a deploy.
626 *
627 * Reads back through get_deployed_schemas() — the same accessor
628 * Frontend\SEO_Manager::output_site_schema_markup() uses to emit schema — so
629 * the check reflects what will actually reach the page rather than only that
630 * an INSERT returned without error.
631 *
632 * @since 1.32.0
633 *
634 * @param string $context_type Context type
635 * @param int|null $context_id Context ID
636 * @param array $expected_types Schema types that were just deployed
637 * @return array Validation result
638 */
639 private function verify_deployment(string $context_type, ?int $context_id, array $expected_types): array {
640 if (empty($expected_types)) {
641 return [
642 'validation_passed' => false,
643 'message' => 'No schema was deployed',
644 'missing_types' => []
645 ];
646 }
647
648 $retrieved = $this->get_deployed_schemas($context_type, $context_id);
649 $missing = array_values(array_diff($expected_types, array_keys($retrieved)));
650
651 if (!empty($missing)) {
652 return [
653 'validation_passed' => false,
654 'message' => sprintf(
655 /* translators: %s: comma-separated list of schema types */
656 __('Deployed schema could not be read back: %s', 'thinkrank'),
657 implode(', ', $missing)
658 ),
659 'missing_types' => $missing
660 ];
661 }
662
663 return [
664 'validation_passed' => true,
665 'message' => __('Schema deployed and verified on the front end', 'thinkrank'),
666 'missing_types' => []
667 ];
668 }
669
670 /**
671 * Track schema performance and rich snippet appearances
672 *
673 * @since 1.0.0
674 *
675 * @param string $context_type Context type
676 * @param int|null $context_id Context ID
677 * @param array $options Tracking options
678 * @return array Schema performance tracking results
679 */
680 public function track_schema_performance(string $context_type, ?int $context_id, array $options = []): array {
681 // Performance tracking is not yet implemented
682 // This method returns empty data structure for API compatibility
683 return [
684 'context_type' => $context_type,
685 'context_id' => $context_id,
686 'rich_snippets_appearances' => [],
687 'search_performance' => [],
688 'click_through_rates' => [],
689 'schema_errors' => [],
690 'performance_trends' => [],
691 'optimization_impact' => [],
692 'tracking_timestamp' => current_time('mysql'),
693 'tracking_enabled' => false,
694 'message' => 'Performance tracking feature is not yet implemented'
695 ];
696 }
697
698 /**
699 * Validate SEO settings (implements interface)
700 *
701 * @since 1.0.0
702 *
703 * @param array $settings Settings array to validate
704 * @return array Validation results
705 */
706 public function validate_settings(array $settings): array {
707 $validation = [
708 'valid' => true,
709 'errors' => [],
710 'warnings' => [],
711 'suggestions' => [],
712 'score' => 100
713 ];
714
715 // Validate schema types configuration
716 if (isset($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
717 foreach ($settings['enabled_schema_types'] as $schema_type) {
718 if (!isset($this->schema_types[$schema_type])) {
719 $validation['errors'][] = "Invalid schema type: {$schema_type}";
720 $validation['valid'] = false;
721 }
722 }
723 }
724
725 // Note: Only JSON-LD deployment method is supported (no validation needed since it's hardcoded)
726
727 // Validate auto-generation settings
728 if (isset($settings['auto_generate_schema']) && !is_bool($settings['auto_generate_schema'])) {
729 $validation['errors'][] = 'Auto-generate schema setting must be boolean';
730 $validation['valid'] = false;
731 }
732
733 // Validate validation requirements
734 if (isset($settings['validation_level'])) {
735 $valid_levels = ['strict', 'moderate', 'lenient'];
736 if (!in_array($settings['validation_level'], $valid_levels, true)) {
737 $validation['errors'][] = 'Invalid validation level specified';
738 $validation['valid'] = false;
739 }
740 }
741
742 // Validate rich snippets optimization
743 if (isset($settings['rich_snippets_optimization']) && !is_bool($settings['rich_snippets_optimization'])) {
744 $validation['errors'][] = 'Rich snippets optimization setting must be boolean';
745 $validation['valid'] = false;
746 }
747
748 // Validate performance tracking
749 if (isset($settings['performance_tracking']) && !is_bool($settings['performance_tracking'])) {
750 $validation['errors'][] = 'Performance tracking setting must be boolean';
751 $validation['valid'] = false;
752 }
753
754 // Validate cache settings
755 if (isset($settings['cache_duration'])) {
756 if (!is_numeric($settings['cache_duration']) || $settings['cache_duration'] < 0) {
757 $validation['errors'][] = 'Cache duration must be a positive number';
758 $validation['valid'] = false;
759 }
760 }
761
762 // Calculate validation score
763 $validation['score'] = $this->calculate_validation_score($validation);
764
765 return $validation;
766 }
767
768 /**
769 * Get output data for frontend rendering (implements interface)
770 *
771 * @since 1.0.0
772 *
773 * @param string $context_type The context type
774 * @param int|null $context_id Optional. Context ID
775 * @return array Output data ready for frontend rendering
776 */
777 public function get_output_data(string $context_type, ?int $context_id): array {
778 $settings = $this->get_settings($context_type, $context_id);
779
780 $output = [
781 'schema_dashboard' => [],
782 'generated_schemas' => [],
783 'validation_results' => [],
784 'rich_snippets_preview' => [],
785 'performance_data' => [],
786 'recommendations' => [],
787 'enabled' => true
788 ];
789
790 // Get enabled schema types
791 $enabled_types = $settings['enabled_schema_types'] ?? [];
792
793 // Auto-generate schema types if enabled and no manual types specified
794 if (empty($enabled_types) && ($settings['auto_generate_schema'] ?? true)) {
795 $enabled_types = $this->auto_detect_schema_types($context_type, $context_id);
796 }
797
798 // Add content-specific schema types based on settings
799 $enabled_types = $this->apply_content_schema_settings($enabled_types, $settings, $context_type);
800
801 if (!empty($enabled_types)) {
802 // Generate schema markup with enhanced options
803 $generation_options = [
804 'knowledge_graph' => $settings['knowledge_graph'] ?? true,
805 'rich_snippets_optimization' => $settings['rich_snippets_optimization'] ?? true,
806 'validation_level' => $settings['validation_level'] ?? 'moderate'
807 ];
808
809 $generation_results = $this->generate_schema_markup($context_type, $context_id, $enabled_types, $generation_options);
810
811 // Populate output data
812 $output['generated_schemas'] = $generation_results['generated_schemas'] ?? [];
813 $output['validation_results'] = $generation_results['validation_results'] ?? [];
814 $output['rich_snippets_preview'] = $generation_results['rich_snippets_preview'] ?? [];
815 $output['recommendations'] = $generation_results['optimization_recommendations'] ?? [];
816
817 // Get schema dashboard data
818 $output['schema_dashboard'] = [
819 'total_schemas' => count($generation_results['generated_schemas'] ?? []),
820 'valid_schemas' => count(array_filter($generation_results['validation_results'] ?? [], function($v) { return $v['is_valid'] ?? false; })),
821 'deployment_ready' => $generation_results['deployment_ready'] ?? false,
822 'last_generated' => current_time('mysql')
823 ];
824
825 // Get performance data if tracking enabled
826 if ($settings['performance_tracking'] ?? false) {
827 $output['performance_data'] = $this->track_schema_performance($context_type, $context_id);
828 }
829 }
830
831 return $output;
832 }
833
834 // Removed knowledge graph enhancements - moved to separate service
835 // Social links and enhanced data handled by Schema_Builder directly
836
837 /**
838 * Apply rich snippets optimization to schema data
839 *
840 * @since 1.0.0
841 *
842 * @param array $schema_data Schema data
843 * @param string $schema_type Schema type
844 * @return array Optimized schema data
845 */
846 private function apply_rich_snippets_optimization(array $schema_data, string $schema_type): array {
847 switch ($schema_type) {
848 case 'Article':
849 case 'BlogPosting':
850 // Rich snippets optimization for articles
851 // Note: Image is optional - only include if user provides one
852 // No default image fallback to avoid non-existent image URLs
853
854 // Optimize headline length for rich snippets
855 if (!empty($schema_data['headline']) && strlen($schema_data['headline']) > 110) {
856 $schema_data['headline'] = substr($schema_data['headline'], 0, 107) . '...';
857 }
858 break;
859
860 case 'Organization':
861 // Ensure logo for rich snippets
862 if (empty($schema_data['logo'])) {
863 $schema_data['logo'] = $this->get_default_organization_logo();
864 }
865 break;
866
867 case 'Product':
868 // Ensure required properties for product rich snippets
869 if (empty($schema_data['offers'])) {
870 $schema_data['offers'] = [
871 '@type' => 'Offer',
872 'availability' => 'https://schema.org/InStock',
873 'priceCurrency' => 'USD'
874 ];
875 }
876 break;
877 }
878
879 return $schema_data;
880 }
881
882 /**
883 * Apply content schema settings from options to enabled types
884 *
885 * @since 1.0.0
886 *
887 * @param array $enabled_types Current enabled types
888 * @param array $options Generation options
889 * @param string $context_type Context type
890 * @return array Enhanced enabled types
891 */
892 private function apply_content_schema_settings_from_options(array $enabled_types, array $options, string $context_type): array {
893 // For site context: only apply site-level schema settings
894 if ($context_type === 'site') {
895 // Add local business schema if enabled
896 if ($options['enable_local_business'] ?? false) {
897 if (!in_array('LocalBusiness', $enabled_types, true)) {
898 $enabled_types[] = 'LocalBusiness';
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 ($options['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 ($options['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 ($options['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 ($options['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 ($options['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 * Apply content schema settings to enabled types
947 *
948 * @since 1.0.0
949 *
950 * @param array $enabled_types Current enabled types
951 * @param array $settings Schema settings
952 * @param string $context_type Context type
953 * @return array Enhanced enabled types
954 */
955 private function apply_content_schema_settings(array $enabled_types, array $settings, string $context_type): array {
956 // For site context: only apply site-level schema settings
957 if ($context_type === 'site') {
958 // Add local business schema if enabled
959 if ($settings['enable_local_business'] ?? false) {
960 if (!in_array('LocalBusiness', $enabled_types, true)) {
961 $enabled_types[] = 'LocalBusiness';
962 }
963 }
964
965 // Add breadcrumbs schema if enabled (site-wide feature)
966 if ($settings['enable_breadcrumbs_schema'] ?? false) {
967 if (!in_array('BreadcrumbList', $enabled_types, true)) {
968 $enabled_types[] = 'BreadcrumbList';
969 }
970 }
971
972 return $enabled_types;
973 }
974
975 // For post/page context: apply all schema settings (metabox functionality)
976
977 // Add article schema if enabled and context is appropriate
978 if ($settings['enable_article_schema'] ?? false) {
979 if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) {
980 $enabled_types[] = 'Article';
981 }
982 }
983
984 // Add FAQ schema if enabled
985 if ($settings['enable_faq_schema'] ?? false) {
986 if (!in_array('FAQPage', $enabled_types, true)) {
987 $enabled_types[] = 'FAQPage';
988 }
989 }
990
991 // Add How-To schema if enabled
992 if ($settings['enable_howto_schema'] ?? false) {
993 if (!in_array('HowTo', $enabled_types, true)) {
994 $enabled_types[] = 'HowTo';
995 }
996 }
997
998 // Add product schema if enabled and context is appropriate
999 if ($settings['enable_product_schema'] ?? false) {
1000 if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) {
1001 $enabled_types[] = 'Product';
1002 }
1003 }
1004
1005 // Add local business schema if enabled
1006 if ($settings['enable_local_business'] ?? false) {
1007 if (!in_array('LocalBusiness', $enabled_types, true)) {
1008 $enabled_types[] = 'LocalBusiness';
1009 }
1010 }
1011
1012 return $enabled_types;
1013 }
1014
1015 /**
1016 * Get default organization logo for rich snippets
1017 *
1018 * @since 1.0.0
1019 *
1020 * @return string Default logo URL
1021 */
1022 private function get_default_organization_logo(): string {
1023 // Try to get custom logo
1024 $custom_logo_id = get_theme_mod('custom_logo');
1025 if ($custom_logo_id) {
1026 $logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
1027 if ($logo_url) {
1028 return $logo_url;
1029 }
1030 }
1031
1032 // Fallback to site icon or default
1033 $site_icon_url = get_site_icon_url();
1034 if ($site_icon_url) {
1035 return $site_icon_url;
1036 }
1037
1038 // Final fallback
1039 return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg');
1040 }
1041
1042 /**
1043 * Get default settings for a context type (implements interface)
1044 *
1045 * @since 1.0.0
1046 *
1047 * @param string $context_type The context type to get defaults for
1048 * @return array Default settings array
1049 */
1050 public function get_default_settings(string $context_type): array {
1051 return Schema_Settings_Config::get_default_settings($context_type);
1052 }
1053
1054 /**
1055 * Get settings schema definition (implements interface)
1056 *
1057 * @since 1.0.0
1058 *
1059 * @param string $context_type The context type to get schema for
1060 * @return array Settings schema definition
1061 */
1062 public function get_settings_schema(string $context_type): array {
1063 return Schema_Settings_Config::get_settings_schema($context_type);
1064 }
1065
1066 /**
1067 * Save SEO settings with cache invalidation and auto-deployment
1068 *
1069 * Overrides parent method to add schema cache invalidation when settings change.
1070 * This ensures cached schema data is refreshed when configuration changes.
1071 * Also triggers auto-deployment of schema when enabled.
1072 *
1073 * @since 1.0.0
1074 *
1075 * @param string $context_type The context type
1076 * @param int|null $context_id Optional. Context ID
1077 * @param array $settings Settings array to save
1078 * @return bool True on success, false on failure
1079 */
1080 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
1081 // Call parent method to save settings
1082 $success = parent::save_settings($context_type, $context_id, $settings);
1083
1084 // CACHE INVALIDATION: Clear all schema cache when settings change
1085 if ($success && $this->cache_manager) {
1086 $this->cache_manager->invalidate_all_cache();
1087 }
1088
1089 // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
1090 if ($success && !empty($settings['auto_deploy'])) {
1091 $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1092 }
1093
1094 return $success;
1095 }
1096
1097 /**
1098 * Auto-deploy schema when settings change
1099 *
1100 * Automatically regenerates and deploys schema markup when organization or other
1101 * schema settings are modified, ensuring the frontend output stays in sync.
1102 *
1103 * @since 1.0.0
1104 *
1105 * @param string $context_type Context type
1106 * @param int|null $context_id Context ID
1107 * @param array $settings Updated settings
1108 * @return void
1109 */
1110 private function auto_deploy_schema_on_settings_change(string $context_type, ?int $context_id, array $settings): void {
1111 // Determine which schema types need to be regenerated based on changed settings
1112 $schema_types_to_regenerate = [];
1113
1114 // Organization schema - regenerate if organization settings changed
1115 if ($this->has_organization_settings_changed($settings)) {
1116 $schema_types_to_regenerate[] = 'Organization';
1117 }
1118
1119 // Website schema - regenerate if website settings changed. The type is
1120 // registered as 'WebSite' (capital S) in Schema_Factory / $schema_types;
1121 // using 'Website' here made generate_schema_markup() silently skip it.
1122 if ($this->has_website_settings_changed($settings)) {
1123 $schema_types_to_regenerate[] = 'WebSite';
1124 }
1125
1126 // LocalBusiness schema - regenerate if business settings changed
1127 if ($this->has_business_settings_changed($settings)) {
1128 $schema_types_to_regenerate[] = 'LocalBusiness';
1129 }
1130
1131 // Person schema - regenerate if person settings changed
1132 if ($this->has_person_settings_changed($settings)) {
1133 $schema_types_to_regenerate[] = 'Person';
1134 }
1135
1136 // If no schema types need regeneration, return early
1137 if (empty($schema_types_to_regenerate)) {
1138 return;
1139 }
1140
1141 // Generate and deploy each schema type
1142 foreach ($schema_types_to_regenerate as $schema_type) {
1143 try {
1144 // Generate schema using generate_schema_markup
1145 $generation_result = $this->generate_schema_markup(
1146 $context_type,
1147 $context_id,
1148 [$schema_type]
1149 );
1150
1151 // Deploy if generation was successful
1152 if (!empty($generation_result['generated_schemas'][$schema_type]) && $generation_result['deployment_ready']) {
1153 $this->deploy_schema_markup(
1154 $context_type,
1155 $context_id,
1156 [$schema_type => $generation_result['generated_schemas'][$schema_type]]
1157 );
1158 }
1159 } catch (\Exception $e) {
1160 // Log error but don't fail the settings save
1161 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1162 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1163 error_log('ThinkRank: Auto-deploy failed for ' . $schema_type . ': ' . $e->getMessage());
1164 }
1165 }
1166 }
1167 }
1168
1169 /**
1170 * Check if organization settings have changed
1171 *
1172 * @since 1.0.0
1173 *
1174 * @param array $settings Updated settings
1175 * @return bool True if organization settings changed
1176 */
1177 private function has_organization_settings_changed(array $settings): bool {
1178 $org_keys = [
1179 'organization_name', 'organization_type', 'organization_logo', 'organization_url',
1180 'organization_description', 'organization_social_facebook', 'organization_social_twitter',
1181 'organization_social_linkedin', 'organization_social_instagram', 'organization_social_youtube',
1182 'organization_social_pinterest', 'organization_social_whatsapp', 'organization_social_telegram',
1183 'organization_contact_type', 'organization_contact_phone', 'organization_contact_email',
1184 'organization_contact_hours'
1185 ];
1186
1187 foreach ($org_keys as $key) {
1188 if (isset($settings[$key])) {
1189 return true;
1190 }
1191 }
1192
1193 return false;
1194 }
1195
1196 /**
1197 * Check if website settings have changed
1198 *
1199 * @since 1.0.0
1200 *
1201 * @param array $settings Updated settings
1202 * @return bool True if website settings changed
1203 */
1204 private function has_website_settings_changed(array $settings): bool {
1205 $website_keys = ['site_name', 'site_description', 'site_url'];
1206
1207 foreach ($website_keys as $key) {
1208 if (isset($settings[$key])) {
1209 return true;
1210 }
1211 }
1212
1213 return false;
1214 }
1215
1216 /**
1217 * Check if business settings have changed
1218 *
1219 * @since 1.0.0
1220 *
1221 * @param array $settings Updated settings
1222 * @return bool True if business settings changed
1223 */
1224 private function has_business_settings_changed(array $settings): bool {
1225 $business_keys = [
1226 'business_name', 'business_type', 'business_address', 'business_phone',
1227 'business_hours', 'business_price_range'
1228 ];
1229
1230 foreach ($business_keys as $key) {
1231 if (isset($settings[$key])) {
1232 return true;
1233 }
1234 }
1235
1236 return false;
1237 }
1238
1239 /**
1240 * Check if person settings have changed
1241 *
1242 * @since 1.0.0
1243 *
1244 * @param array $settings Updated settings
1245 * @return bool True if person settings changed
1246 */
1247 private function has_person_settings_changed(array $settings): bool {
1248 $person_keys = ['person_name', 'person_image', 'person_job_title', 'person_description'];
1249
1250 foreach ($person_keys as $key) {
1251 if (isset($settings[$key])) {
1252 return true;
1253 }
1254 }
1255
1256 return false;
1257 }
1258
1259 /**
1260 * Auto-detect appropriate schema types for context
1261 *
1262 * @since 1.0.0
1263 *
1264 * @param string $context_type Context type
1265 * @param int|null $context_id Context ID
1266 * @return array Detected schema types
1267 */
1268 private function auto_detect_schema_types(string $context_type, ?int $context_id): array {
1269 $detected_types = [];
1270
1271 switch ($context_type) {
1272 case 'site':
1273 $detected_types = ['Organization'];
1274 // Check if it's a local business
1275 if ($this->is_local_business()) {
1276 $detected_types[] = 'LocalBusiness';
1277 }
1278 break;
1279 case 'post':
1280 $detected_types = ['Article'];
1281 // Check content type for specific article types
1282 if ($context_id) {
1283 $post = get_post($context_id);
1284 if ($post && $this->is_how_to_content($post->post_content)) {
1285 $detected_types[] = 'HowTo';
1286 }
1287 }
1288 break;
1289 case 'page':
1290 $detected_types = ['Article'];
1291 if ($context_id) {
1292 $page = get_post($context_id);
1293 if ($page && $this->is_faq_content($page->post_content)) {
1294 $detected_types[] = 'FAQPage';
1295 }
1296 }
1297 break;
1298 case 'product':
1299 $detected_types = ['Product'];
1300 break;
1301 }
1302
1303 return $detected_types;
1304 }
1305
1306 /**
1307 * Prepare content data for Schema_Builder
1308 *
1309 * @since 1.0.0
1310 *
1311 * @param string $context_type Context type
1312 * @param int|null $context_id Context ID
1313 * @param array $content_analysis Content analysis data
1314 * @param array $optimization_data Optimization data
1315 * @param array $options Generation options (may contain custom content_data)
1316 * @return array Prepared content data for schema generation
1317 */
1318 private function prepare_content_data_for_generator(string $context_type, ?int $context_id, array $content_analysis, array $optimization_data, array $options = []): array {
1319 $content_data = [];
1320
1321 // Handle different context types
1322 if ($context_type === 'site') {
1323 // Site-level data
1324 $content_data = [
1325 'title' => get_bloginfo('name'),
1326 'url' => home_url(),
1327 'excerpt' => get_bloginfo('description'),
1328 'content' => get_bloginfo('description'),
1329 'business_data' => $this->get_business_data_from_local_seo(),
1330 'site_data' => $this->get_site_data_for_schema(),
1331 'social_data' => $this->get_social_data_for_schema()
1332 ];
1333 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1334 // Post/page/product data
1335 $post = get_post($context_id);
1336 if ($post) {
1337 $content_data = [
1338 'title' => $post->post_title,
1339 'url' => get_permalink($post->ID),
1340 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
1341 'content' => $post->post_content,
1342 'author' => [
1343 'name' => get_the_author_meta('display_name', $post->post_author),
1344 'url' => get_author_posts_url($post->post_author)
1345 ],
1346 'date' => $post->post_date,
1347 'modified' => $post->post_modified,
1348 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1349 'focus_keywords' => Focus_Keywords::get($post->ID),
1350 'business_data' => $this->get_business_data_from_local_seo(),
1351 'site_data' => $this->get_site_data_for_schema(),
1352 'social_data' => $this->get_social_data_for_schema()
1353 ];
1354
1355 // Override with custom content data if provided (for metabox usage)
1356 if (!empty($options['content_data'])) {
1357 $custom_data = $options['content_data'];
1358
1359 // Override title if provided and not empty
1360 if (!empty($custom_data['title'])) {
1361 $content_data['title'] = $custom_data['title'];
1362 }
1363
1364 // Override excerpt/description if provided and not empty
1365 if (!empty($custom_data['description'])) {
1366 $content_data['excerpt'] = $custom_data['description'];
1367 }
1368
1369 // Override content if provided and not empty
1370 if (!empty($custom_data['content'])) {
1371 $content_data['content'] = $custom_data['content'];
1372 }
1373
1374 // Override URL if provided and not empty, but ensure it's the post permalink, not admin URL
1375 if (!empty($custom_data['post_url'])) {
1376 // If the URL is an admin edit URL, convert it to the post permalink
1377 if (strpos($custom_data['post_url'], 'wp-admin/post.php') !== false && $context_id) {
1378 $content_data['url'] = get_permalink($context_id);
1379 } else {
1380 $content_data['url'] = $custom_data['post_url'];
1381 }
1382 }
1383
1384 // Add focus keyword(s) if provided
1385 if (!empty($custom_data['focus_keywords']) && is_array($custom_data['focus_keywords'])) {
1386 $content_data['focus_keywords'] = $custom_data['focus_keywords'];
1387 }
1388 if (!empty($custom_data['focus_keyword'])) {
1389 $content_data['focus_keyword'] = $custom_data['focus_keyword'];
1390 }
1391
1392 // Add word count if provided (from frontend calculation)
1393 if (!empty($custom_data['word_count'])) {
1394 $content_data['word_count'] = (int) $custom_data['word_count'];
1395 }
1396
1397 // CRITICAL: Override site_data fields that take precedence in schema builder
1398 // The schema builder checks site_data first, so we need to clear these
1399 // to ensure our custom data is used instead
1400 if (isset($content_data['site_data'])) {
1401 // Clear site-level article settings so custom data takes precedence
1402 unset($content_data['site_data']['article_headline']);
1403 unset($content_data['site_data']['article_description']);
1404 unset($content_data['site_data']['article_author']);
1405 }
1406 }
1407
1408 // Merge schema form data into site_data if provided (for content-specific schemas)
1409 if (!empty($options['schema_form_data'])) {
1410 $form_data = $options['schema_form_data'];
1411
1412 // Ensure site_data exists
1413 if (!isset($content_data['site_data'])) {
1414 $content_data['site_data'] = [];
1415 }
1416
1417 // Merge form data into site_data so schema builder can access it
1418 $content_data['site_data'] = array_merge($content_data['site_data'], $form_data);
1419 }
1420
1421 // Simplified: Content analysis moved to separate services
1422 // Word count and reading time handled by Schema_Builder directly from content
1423 }
1424 }
1425
1426 return $content_data;
1427 }
1428
1429 /**
1430 * Store schema data in database
1431 *
1432 * @since 1.0.0
1433 *
1434 * @param string $context_type Context type
1435 * @param int|null $context_id Context ID
1436 * @param array $generation Generation results
1437 * @return bool Success status
1438 */
1439 private function store_schema_data(string $context_type, ?int $context_id, array $generation): bool {
1440 global $wpdb;
1441
1442 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1443
1444 // First, delete all existing schemas for this context to ensure clean storage
1445 $this->delete_existing_schemas($context_type, $context_id);
1446
1447 foreach ($generation['generated_schemas'] as $schema_type => $schema_data) {
1448 // Prepare schema data with validation status embedded
1449 $schema_data_with_validation = $schema_data;
1450 $schema_data_with_validation['_validation'] = [
1451 'is_valid' => $generation['validation_results'][$schema_type]['is_valid'],
1452 'errors' => $generation['validation_results'][$schema_type]['errors'] ?? [],
1453 'warnings' => $generation['validation_results'][$schema_type]['warnings'] ?? [],
1454 'score' => $generation['validation_results'][$schema_type]['validation_score'] ?? 0
1455 ];
1456
1457 $data = [
1458 'context_type' => $context_type,
1459 'context_id' => $context_id,
1460 'schema_type' => $schema_type,
1461 'schema_data' => wp_json_encode($schema_data_with_validation),
1462 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid',
1463 'is_active' => $generation['deployment_ready'] ? 1 : 0
1464 ];
1465
1466 // Insert new schema (existing ones were already deleted)
1467 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
1468 $wpdb->insert($table_name, $data);
1469 }
1470
1471 // CACHE INVALIDATION: Clear cache after storing new schema data
1472 if ($this->cache_manager) {
1473 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
1474 }
1475
1476 return true;
1477 }
1478
1479 /**
1480 * Calculate validation score
1481 *
1482 * @since 1.0.0
1483 *
1484 * @param array $validation Validation results
1485 * @return int Score (0-100)
1486 */
1487 private function calculate_validation_score(array $validation): int {
1488 $score = 100;
1489 $score -= count($validation['errors'] ?? []) * 20;
1490 $score -= count($validation['warnings'] ?? []) * 10;
1491 $score -= count($validation['suggestions'] ?? []) * 5;
1492
1493 return max(0, (int) round($score));
1494 }
1495
1496 /**
1497 * Simple implementations for helper methods referenced in the main functions
1498 * These would be enhanced with more sophisticated algorithms in production
1499 */
1500
1501 // Removed complex AI integration methods - moved to separate services
1502 // Schema management focuses on core structured data generation
1503 private function generate_rich_snippets_preview(array $schema_data, string $schema_type): array {
1504 return [
1505 'preview_type' => $schema_type,
1506 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title',
1507 'description' => $schema_data['description'] ?? 'Description',
1508 'image' => $schema_data['image']['url'] ?? $schema_data['image'] ?? null,
1509 'additional_info' => $this->extract_additional_info($schema_data, $schema_type)
1510 ];
1511 }
1512
1513 private function extract_additional_info(array $schema_data, string $schema_type): array {
1514 $info = [];
1515
1516 switch ($schema_type) {
1517 case 'Article':
1518 if (isset($schema_data['author']['name'])) {
1519 $info['author'] = $schema_data['author']['name'];
1520 }
1521 if (isset($schema_data['datePublished'])) {
1522 $timestamp = strtotime($schema_data['datePublished']);
1523 if ($timestamp !== false) {
1524 $info['date'] = gmdate('M j, Y', $timestamp);
1525 }
1526 }
1527 break;
1528 case 'Product':
1529 if (isset($schema_data['offers']['price'])) {
1530 $info['price'] = $schema_data['offers']['priceCurrency'] . $schema_data['offers']['price'];
1531 }
1532 if (isset($schema_data['brand']['name'])) {
1533 $info['brand'] = $schema_data['brand']['name'];
1534 }
1535 break;
1536 }
1537
1538 return $info;
1539 }
1540
1541 private function generate_schema_optimization_recommendations(array $generated_schemas, array $validation_results): array {
1542 $recommendations = [];
1543
1544 foreach ($validation_results as $schema_type => $validation) {
1545 if (!$validation['is_valid']) {
1546 $recommendations[] = [
1547 'type' => 'validation_error',
1548 'schema_type' => $schema_type,
1549 'priority' => 'high',
1550 'message' => "Schema validation failed for {$schema_type}",
1551 'action' => 'Fix validation errors before deployment'
1552 ];
1553 }
1554
1555 if (!empty($validation['warnings'])) {
1556 // Extract missing properties from warnings
1557 $missing_properties = [];
1558 foreach ($validation['warnings'] as $warning) {
1559 if (strpos($warning, 'Missing recommended property:') === 0) {
1560 $property = trim(str_replace('Missing recommended property:', '', $warning));
1561 $missing_properties[] = $property;
1562 }
1563 }
1564
1565 if (!empty($missing_properties)) {
1566 $properties_list = implode(', ', $missing_properties);
1567 $recommendations[] = [
1568 'type' => 'missing_properties',
1569 'schema_type' => $schema_type,
1570 'priority' => 'medium',
1571 'message' => "Missing recommended properties for {$schema_type}: {$properties_list}",
1572 'action' => 'Add these properties to improve rich snippets eligibility'
1573 ];
1574 } else {
1575 $recommendations[] = [
1576 'type' => 'missing_properties',
1577 'schema_type' => $schema_type,
1578 'priority' => 'medium',
1579 'message' => "Missing recommended properties for {$schema_type}",
1580 'action' => 'Add recommended properties to improve rich snippets eligibility'
1581 ];
1582 }
1583 }
1584 }
1585
1586 return $recommendations;
1587 }
1588
1589 private function check_deployment_readiness(array $validation_results): bool {
1590 foreach ($validation_results as $validation) {
1591 if (!$validation['is_valid']) {
1592 return false;
1593 }
1594 }
1595 return true;
1596 }
1597
1598 // Content detection helper methods
1599 private function is_local_business(): bool {
1600 // Simple check - would be enhanced with actual business detection
1601 $description = get_bloginfo('description');
1602 $local_keywords = ['restaurant', 'shop', 'store', 'clinic', 'office', 'service'];
1603
1604 foreach ($local_keywords as $keyword) {
1605 if (stripos($description, $keyword) !== false) {
1606 return true;
1607 }
1608 }
1609
1610 return false;
1611 }
1612
1613 private function is_how_to_content(string $content): bool {
1614 $how_to_keywords = ['step', 'how to', 'tutorial', 'guide', 'instructions'];
1615 $content_lower = strtolower($content);
1616
1617 foreach ($how_to_keywords as $keyword) {
1618 if (stripos($content_lower, $keyword) !== false) {
1619 return true;
1620 }
1621 }
1622
1623 return false;
1624 }
1625
1626 private function is_faq_content(string $content): bool {
1627 $faq_keywords = ['faq', 'frequently asked', 'questions', 'q:', 'a:'];
1628 $content_lower = strtolower($content);
1629
1630 foreach ($faq_keywords as $keyword) {
1631 if (stripos($content_lower, $keyword) !== false) {
1632 return true;
1633 }
1634 }
1635
1636 return false;
1637 }
1638
1639 private function determine_deployment_method(array $options): string {
1640 // Always use JSON-LD as it's the only supported method
1641 return 'json_ld';
1642 }
1643
1644 private function deploy_single_schema(array $schema, string $schema_type, string $method, string $context_type, ?int $context_id): array {
1645 global $wpdb;
1646
1647 // Use existing seo_schema table
1648 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1649
1650 $deployment_data = [
1651 'context_type' => $context_type,
1652 'context_id' => $context_id,
1653 'schema_type' => $schema_type,
1654 'schema_data' => wp_json_encode($schema),
1655 'validation_status' => 'deployed',
1656 'is_active' => 1
1657 ];
1658
1659 // Check if schema already exists for this context and type
1660 if (null === $context_id) {
1661 // Handle NULL context_id case
1662 // 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
1663 $sql = sprintf(
1664 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id IS NULL AND schema_type = %%s',
1665 $table_name
1666 );
1667 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access
1668 $existing = $wpdb->get_var(
1669 $wpdb->prepare(
1670 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1671 $sql,
1672 $context_type,
1673 $schema_type
1674 )
1675 );
1676 } else {
1677 // Handle regular context_id case
1678 // 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
1679 $sql = sprintf(
1680 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id = %%d AND schema_type = %%s',
1681 $table_name
1682 );
1683 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access
1684 $existing = $wpdb->get_var(
1685 $wpdb->prepare(
1686 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1687 $sql,
1688 $context_type,
1689 $context_id,
1690 $schema_type
1691 )
1692 );
1693 }
1694
1695 if ($existing) {
1696 // Update existing deployment
1697 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema update requires direct database access
1698 $result = $wpdb->update(
1699 $table_name,
1700 [
1701 'schema_data' => wp_json_encode($schema),
1702 'validation_status' => 'deployed',
1703 'is_active' => 1,
1704 'updated_at' => current_time('mysql')
1705 ],
1706 ['schema_id' => $existing],
1707 ['%s', '%s', '%d', '%s'],
1708 ['%d']
1709 );
1710
1711 } else {
1712 // Insert new deployment
1713 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
1714 $result = $wpdb->insert(
1715 $table_name,
1716 $deployment_data,
1717 ['%s', '%d', '%s', '%s', '%s', '%d']
1718 );
1719
1720 }
1721
1722 return [
1723 'deployed' => $result !== false,
1724 'method' => $method,
1725 'schema_type' => $schema_type,
1726 'schema_id' => $existing ?: $wpdb->insert_id
1727 ];
1728 }
1729
1730 /**
1731 * Get deployed schemas for frontend integration
1732 *
1733 * PERFORMANCE OPTIMIZED: This method now uses:
1734 * 1. Schema caching layer (90% reduction in database queries)
1735 * 2. Window function approach instead of correlated subquery (80-90% query performance improvement)
1736 * 3. Composite index: idx_context_schema_active
1737 *
1738 * @since 1.0.0
1739 *
1740 * @param string $context_type Context type
1741 * @param int|null $context_id Context ID
1742 * @return array Deployed schemas for current context
1743 */
1744 public function get_deployed_schemas(string $context_type = 'site', ?int $context_id = null): array {
1745 // CACHE LAYER: Check cache first for immediate 90% performance improvement
1746 if ($this->cache_manager) {
1747 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
1748 $cached_data = $this->cache_manager->get($cache_key);
1749
1750 if ($cached_data !== null) {
1751 // CACHE FIX: Extract actual data from cache wrapper
1752 return isset($cached_data['data']) ? $cached_data['data'] : $cached_data;
1753 }
1754 }
1755
1756 global $wpdb;
1757
1758 // Use existing seo_schema table
1759 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1760
1761 // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery
1762 // This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC)
1763 if (null === $context_id) {
1764 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1765 $sql = sprintf(
1766 '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',
1767 $table_name
1768 );
1769 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1770 $deployed_schemas = $wpdb->get_results(
1771 $wpdb->prepare(
1772 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1773 $sql,
1774 $context_type
1775 ),
1776 ARRAY_A
1777 );
1778 } else {
1779 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1780 $sql = sprintf(
1781 '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',
1782 $table_name
1783 );
1784 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1785 $deployed_schemas = $wpdb->get_results(
1786 $wpdb->prepare(
1787 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1788 $sql,
1789 $context_type,
1790 $context_id
1791 ),
1792 ARRAY_A
1793 );
1794 }
1795
1796 if (empty($deployed_schemas)) {
1797 return [];
1798 }
1799
1800 // Process schemas for return
1801 $processed_schemas = [];
1802 foreach ($deployed_schemas as $deployed_schema) {
1803 $schema_data = json_decode($deployed_schema['schema_data'], true);
1804 $schema_type = $deployed_schema['schema_type'];
1805
1806 if (!empty($schema_data)) {
1807 // Remove internal validation metadata before frontend output
1808 if (isset($schema_data['_validation'])) {
1809 unset($schema_data['_validation']);
1810 }
1811
1812 $processed_schemas[$schema_type] = [
1813 'data' => $schema_data,
1814 'method' => 'json_ld', // Default method
1815 'type' => $schema_type
1816 ];
1817 }
1818 }
1819
1820 // CACHE LAYER: Store result in cache for future requests
1821 if ($this->cache_manager && !empty($processed_schemas)) {
1822 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
1823 $this->cache_manager->set($cache_key, $processed_schemas);
1824 }
1825
1826 return $processed_schemas;
1827 }
1828
1829 /**
1830 * Clean up duplicate schemas in database
1831 *
1832 * @since 1.0.0
1833 *
1834 * @param string $context_type Context type
1835 * @param int|null $context_id Context ID
1836 * @return int Number of duplicate schemas removed
1837 */
1838 public function cleanup_duplicate_schemas(string $context_type = 'site', ?int $context_id = null): int {
1839 global $wpdb;
1840
1841 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1842
1843 if (null === $context_id) {
1844 // Clean up duplicates for NULL context_id
1845 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
1846 $sql = sprintf(
1847 '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',
1848 $table_name,
1849 $table_name
1850 );
1851 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
1852 $deleted = $wpdb->query(
1853 $wpdb->prepare(
1854 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1855 $sql,
1856 $context_type,
1857 $context_type
1858 )
1859 );
1860 } else {
1861 // Clean up duplicates for specific context_id
1862 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
1863 $sql = sprintf(
1864 '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',
1865 $table_name,
1866 $table_name
1867 );
1868 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
1869 $deleted = $wpdb->query(
1870 $wpdb->prepare(
1871 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1872 $sql,
1873 $context_type,
1874 $context_id,
1875 $context_type,
1876 $context_id
1877 )
1878 );
1879 }
1880
1881 return $deleted ?: 0;
1882 }
1883 /**
1884 * Delete all existing schemas for a context before storing new ones
1885 *
1886 * @since 1.0.0
1887 *
1888 * @param string $context_type Context type
1889 * @param int|null $context_id Context ID
1890 * @return int Number of schemas deleted
1891 */
1892 private function delete_existing_schemas(string $context_type, ?int $context_id): int {
1893 global $wpdb;
1894
1895 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1896
1897 if (null === $context_id) {
1898 // Delete all schemas for NULL context_id
1899 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1900 $sql = sprintf(
1901 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL',
1902 $table_name
1903 );
1904 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1905 $deleted = $wpdb->query(
1906 $wpdb->prepare(
1907 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1908 $sql,
1909 $context_type
1910 )
1911 );
1912 } else {
1913 // Delete all schemas for specific context_id
1914 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1915 $sql = sprintf(
1916 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d',
1917 $table_name
1918 );
1919 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1920 $deleted = $wpdb->query(
1921 $wpdb->prepare(
1922 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1923 $sql,
1924 $context_type,
1925 $context_id
1926 )
1927 );
1928 }
1929
1930 return $deleted ?: 0;
1931 }
1932
1933 /**
1934 * Get business data from Site Identity Local settings
1935 *
1936 * @return array
1937 */
1938 private function get_business_data_from_local_seo(): array {
1939 // Get Site Identity settings which include Local SEO data
1940 $site_identity_settings = get_option('thinkrank_site_identity_settings', []);
1941
1942 return [
1943 'business_name' => $site_identity_settings['business_name'] ?? '',
1944 'business_address' => $site_identity_settings['business_address'] ?? '',
1945 'business_city' => $site_identity_settings['business_city'] ?? '',
1946 'business_state' => $site_identity_settings['business_state'] ?? '',
1947 'business_postal_code' => $site_identity_settings['business_postal_code'] ?? '',
1948 'business_country' => $site_identity_settings['business_country'] ?? '',
1949 'business_phone' => $site_identity_settings['business_phone'] ?? '',
1950 'business_email' => $site_identity_settings['business_email'] ?? '',
1951 'business_hours' => $site_identity_settings['business_hours'] ?? [],
1952 'business_type' => $site_identity_settings['business_type'] ?? 'LocalBusiness'
1953 ];
1954 }
1955 public function get_settings(string $context_type, ?int $context_id = null): array {
1956 // Get base settings from parent
1957 $settings = parent::get_settings($context_type, $context_id);
1958
1959 // For site context, automatically include Site Identity data
1960 if ($context_type === 'site') {
1961 // Get Site Identity settings from the Site Identity Manager
1962 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
1963 $site_identity_settings = $site_identity_manager->get_settings('site', null);
1964
1965 // Include Site Identity assets if not already set in Schema Manager
1966 if (empty($settings['logo_url']) && !empty($site_identity_settings['logo_url'])) {
1967 $settings['logo_url'] = $site_identity_settings['logo_url'];
1968 }
1969 if (empty($settings['favicon_url']) && !empty($site_identity_settings['favicon_url'])) {
1970 $settings['favicon_url'] = $site_identity_settings['favicon_url'];
1971 }
1972 if (empty($settings['apple_touch_icon_url']) && !empty($site_identity_settings['apple_touch_icon_url'])) {
1973 $settings['apple_touch_icon_url'] = $site_identity_settings['apple_touch_icon_url'];
1974 }
1975
1976 // Include Site Identity organization data if not already set in Schema Manager
1977 if (empty($settings['organization_name']) && !empty($site_identity_settings['site_name'])) {
1978 $settings['organization_name'] = $site_identity_settings['site_name'];
1979 }
1980 if (empty($settings['organization_url']) && !empty($site_identity_settings['site_url'])) {
1981 $settings['organization_url'] = $site_identity_settings['site_url'];
1982 }
1983 if (empty($settings['organization_description']) && !empty($site_identity_settings['site_description'])) {
1984 $settings['organization_description'] = $site_identity_settings['site_description'];
1985 }
1986 }
1987
1988 return $settings;
1989 }
1990
1991 /**
1992 * Get site data for rich schema generation
1993 *
1994 * @return array
1995 */
1996 private function get_site_data_for_schema(): array {
1997 // Get Schema Manager's own settings first (highest priority)
1998 $schema_settings = $this->get_settings('site', null);
1999
2000 // Get Site Identity settings for additional data
2001 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
2002 $site_identity_settings = $site_identity_manager->get_settings('site', null);
2003
2004 return [
2005 'site_name' => get_bloginfo('name'),
2006 'site_description' => get_bloginfo('description'),
2007 'site_url' => home_url(),
2008 'admin_email' => get_option('admin_email'),
2009 'language' => get_locale(),
2010 'timezone' => get_option('timezone_string'),
2011 'founded_date' => $site_identity_settings['founded_date'] ?? '',
2012 'founder_name' => $site_identity_settings['founder_name'] ?? '',
2013 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
2014 // Site Identity assets
2015 'logo_url' => $site_identity_settings['logo_url'] ?? '',
2016 'favicon_url' => $site_identity_settings['favicon_url'] ?? '',
2017 // Schema Manager organization settings (highest priority)
2018 'organization_name' => $schema_settings['organization_name'] ?? '',
2019 'organization_description' => $schema_settings['organization_description'] ?? '',
2020 'organization_url' => $schema_settings['organization_url'] ?? '',
2021
2022 // Removed post/page-specific schema settings (Product, Event, Article, Software Application)
2023 // These are now handled only at the post/page level via metabox
2024
2025 // Person schema settings (site-wide)
2026 'person_name' => $schema_settings['person_name'] ?? '',
2027 'person_job_title' => $schema_settings['person_job_title'] ?? '',
2028 'person_description' => $schema_settings['person_description'] ?? '',
2029 'person_image' => $schema_settings['person_image'] ?? '',
2030 'person_url' => $schema_settings['person_url'] ?? '',
2031 'person_email' => $schema_settings['person_email'] ?? '',
2032 'person_telephone' => $schema_settings['person_telephone'] ?? '',
2033 'person_address' => $schema_settings['person_address'] ?? '',
2034 'person_birth_date' => $schema_settings['person_birth_date'] ?? '',
2035 'person_nationality' => $schema_settings['person_nationality'] ?? '',
2036 'person_works_for' => $schema_settings['person_works_for'] ?? '',
2037 'person_same_as' => $schema_settings['person_same_as'] ?? [],
2038
2039 // Website schema settings (site-wide)
2040 'website_name' => $schema_settings['website_name'] ?? '',
2041 'website_url' => $schema_settings['website_url'] ?? '',
2042 'website_description' => $schema_settings['website_description'] ?? '',
2043 'website_author' => $schema_settings['website_author'] ?? '',
2044
2045 'organization_logo' => $schema_settings['organization_logo'] ?? '',
2046 // Social media links (sameAs)
2047 'organization_social_facebook' => $schema_settings['organization_social_facebook'] ?? '',
2048 'organization_social_twitter' => $schema_settings['organization_social_twitter'] ?? '',
2049 'organization_social_linkedin' => $schema_settings['organization_social_linkedin'] ?? '',
2050 'organization_social_instagram' => $schema_settings['organization_social_instagram'] ?? '',
2051 'organization_social_youtube' => $schema_settings['organization_social_youtube'] ?? '',
2052 'organization_social_pinterest' => $schema_settings['organization_social_pinterest'] ?? '',
2053 'organization_social_whatsapp' => $schema_settings['organization_social_whatsapp'] ?? '',
2054 'organization_social_telegram' => $schema_settings['organization_social_telegram'] ?? '',
2055 // Contact point information
2056 'organization_contact_type' => $schema_settings['organization_contact_type'] ?? 'customer service',
2057 'organization_contact_phone' => $schema_settings['organization_contact_phone'] ?? '',
2058 'organization_contact_email' => $schema_settings['organization_contact_email'] ?? '',
2059 'organization_contact_hours' => $schema_settings['organization_contact_hours'] ?? '',
2060 // LocalBusiness specific fields
2061 'business_price_range' => $schema_settings['business_price_range'] ?? '',
2062 'business_geo_latitude' => $schema_settings['business_geo_latitude'] ?? '',
2063 'business_geo_longitude' => $schema_settings['business_geo_longitude'] ?? '',
2064 'business_opening_hours' => $schema_settings['business_opening_hours'] ?? []
2065 ];
2066 }
2067
2068 /**
2069 * Get social media data for schema generation
2070 *
2071 * @return array
2072 */
2073 private function get_social_data_for_schema(): array {
2074 // Get Social Media settings
2075 $social_settings = get_option('thinkrank_social_media_settings', []);
2076
2077 $social_profiles = [];
2078
2079 // Common social platforms
2080 $platforms = ['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'tiktok', 'pinterest'];
2081
2082 foreach ($platforms as $platform) {
2083 $url = $social_settings["{$platform}_url"] ?? '';
2084 if (!empty($url)) {
2085 $social_profiles[] = $url;
2086 }
2087 }
2088
2089 return [
2090 'social_profiles' => $social_profiles,
2091 'social_settings' => $social_settings
2092 ];
2093 }
2094 }
2095