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

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

2,140 lines 87.7 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 * Schema keys outside the shared config defaults.
1044 *
1045 * @since 2.0.1
1046 *
1047 * @return string[]
1048 */
1049 protected function additional_setting_keys(): array {
1050 return [
1051 'enable_article_schema', 'enable_product_schema',
1052 'enable_faq_schema', 'enable_howto_schema',
1053 ];
1054 }
1055
1056 /**
1057 * Per-entity schema fields are an open set.
1058 *
1059 * Each schema type the UI can edit contributes its own field family —
1060 * organization_*, person_*, website_*, business_*, software_*, howto_* —
1061 * and a new type adds another. The families this manager owns are matched
1062 * rather than enumerated, so adding a form does not silently start
1063 * dropping its fields (#452).
1064 *
1065 * @since 2.0.1
1066 *
1067 * @return string[]
1068 */
1069 protected function dynamic_setting_key_patterns(): array {
1070 return [
1071 '/^organization_[a-z0-9_]+$/',
1072 '/^person_[a-z0-9_]+$/',
1073 '/^website_[a-z0-9_]+$/',
1074 '/^business_[a-z0-9_]+$/',
1075 '/^software_[a-z0-9_]+$/',
1076 '/^howto_[a-z0-9_]+$/',
1077 '/^product_[a-z0-9_]+$/',
1078 ];
1079 }
1080
1081 /**
1082 * Get default settings for a context type (implements interface)
1083 *
1084 * @since 1.0.0
1085 *
1086 * @param string $context_type The context type to get defaults for
1087 * @return array Default settings array
1088 */
1089 public function get_default_settings(string $context_type): array {
1090 return Schema_Settings_Config::get_default_settings($context_type);
1091 }
1092
1093 /**
1094 * Get settings schema definition (implements interface)
1095 *
1096 * @since 1.0.0
1097 *
1098 * @param string $context_type The context type to get schema for
1099 * @return array Settings schema definition
1100 */
1101 public function get_settings_schema(string $context_type): array {
1102 return Schema_Settings_Config::get_settings_schema($context_type);
1103 }
1104
1105 /**
1106 * Save SEO settings with cache invalidation and auto-deployment
1107 *
1108 * Overrides parent method to add schema cache invalidation when settings change.
1109 * This ensures cached schema data is refreshed when configuration changes.
1110 * Also triggers auto-deployment of schema when enabled.
1111 *
1112 * @since 1.0.0
1113 *
1114 * @param string $context_type The context type
1115 * @param int|null $context_id Optional. Context ID
1116 * @param array $settings Settings array to save
1117 * @return bool True on success, false on failure
1118 */
1119 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
1120 // Call parent method to save settings
1121 $success = parent::save_settings($context_type, $context_id, $settings);
1122
1123 // CACHE INVALIDATION: Clear all schema cache when settings change
1124 if ($success && $this->cache_manager) {
1125 $this->cache_manager->invalidate_all_cache();
1126 }
1127
1128 // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
1129 if ($success && !empty($settings['auto_deploy'])) {
1130 $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1131 }
1132
1133 return $success;
1134 }
1135
1136 /**
1137 * Auto-deploy schema when settings change
1138 *
1139 * Automatically regenerates and deploys schema markup when organization or other
1140 * schema settings are modified, ensuring the frontend output stays in sync.
1141 *
1142 * @since 1.0.0
1143 *
1144 * @param string $context_type Context type
1145 * @param int|null $context_id Context ID
1146 * @param array $settings Updated settings
1147 * @return void
1148 */
1149 private function auto_deploy_schema_on_settings_change(string $context_type, ?int $context_id, array $settings): void {
1150 // Determine which schema types need to be regenerated based on changed settings
1151 $schema_types_to_regenerate = [];
1152
1153 // Organization schema - regenerate if organization settings changed
1154 if ($this->has_organization_settings_changed($settings)) {
1155 $schema_types_to_regenerate[] = 'Organization';
1156 }
1157
1158 // Website schema - regenerate if website settings changed. The type is
1159 // registered as 'WebSite' (capital S) in Schema_Factory / $schema_types;
1160 // using 'Website' here made generate_schema_markup() silently skip it.
1161 if ($this->has_website_settings_changed($settings)) {
1162 $schema_types_to_regenerate[] = 'WebSite';
1163 }
1164
1165 // LocalBusiness schema - regenerate if business settings changed
1166 if ($this->has_business_settings_changed($settings)) {
1167 $schema_types_to_regenerate[] = 'LocalBusiness';
1168 }
1169
1170 // Person schema - regenerate if person settings changed
1171 if ($this->has_person_settings_changed($settings)) {
1172 $schema_types_to_regenerate[] = 'Person';
1173 }
1174
1175 // If no schema types need regeneration, return early
1176 if (empty($schema_types_to_regenerate)) {
1177 return;
1178 }
1179
1180 // Generate and deploy each schema type
1181 foreach ($schema_types_to_regenerate as $schema_type) {
1182 try {
1183 // Generate schema using generate_schema_markup
1184 $generation_result = $this->generate_schema_markup(
1185 $context_type,
1186 $context_id,
1187 [$schema_type]
1188 );
1189
1190 // Deploy if generation was successful
1191 if (!empty($generation_result['generated_schemas'][$schema_type]) && $generation_result['deployment_ready']) {
1192 $this->deploy_schema_markup(
1193 $context_type,
1194 $context_id,
1195 [$schema_type => $generation_result['generated_schemas'][$schema_type]]
1196 );
1197 }
1198 } catch (\Exception $e) {
1199 // Log error but don't fail the settings save
1200 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1201 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1202 error_log('ThinkRank: Auto-deploy failed for ' . $schema_type . ': ' . $e->getMessage());
1203 }
1204 }
1205 }
1206 }
1207
1208 /**
1209 * Check if organization settings have changed
1210 *
1211 * @since 1.0.0
1212 *
1213 * @param array $settings Updated settings
1214 * @return bool True if organization settings changed
1215 */
1216 private function has_organization_settings_changed(array $settings): bool {
1217 $org_keys = [
1218 'organization_name', 'organization_type', 'organization_logo', 'organization_url',
1219 'organization_description', 'organization_social_facebook', 'organization_social_twitter',
1220 'organization_social_linkedin', 'organization_social_instagram', 'organization_social_youtube',
1221 'organization_social_pinterest', 'organization_social_whatsapp', 'organization_social_telegram',
1222 'organization_contact_type', 'organization_contact_phone', 'organization_contact_email',
1223 'organization_contact_hours'
1224 ];
1225
1226 foreach ($org_keys as $key) {
1227 if (isset($settings[$key])) {
1228 return true;
1229 }
1230 }
1231
1232 return false;
1233 }
1234
1235 /**
1236 * Check if website settings have changed
1237 *
1238 * @since 1.0.0
1239 *
1240 * @param array $settings Updated settings
1241 * @return bool True if website settings changed
1242 */
1243 private function has_website_settings_changed(array $settings): bool {
1244 $website_keys = ['site_name', 'site_description', 'site_url'];
1245
1246 foreach ($website_keys as $key) {
1247 if (isset($settings[$key])) {
1248 return true;
1249 }
1250 }
1251
1252 return false;
1253 }
1254
1255 /**
1256 * Check if business settings have changed
1257 *
1258 * @since 1.0.0
1259 *
1260 * @param array $settings Updated settings
1261 * @return bool True if business settings changed
1262 */
1263 private function has_business_settings_changed(array $settings): bool {
1264 $business_keys = [
1265 'business_name', 'business_type', 'business_address', 'business_phone',
1266 'business_hours', 'business_price_range'
1267 ];
1268
1269 foreach ($business_keys as $key) {
1270 if (isset($settings[$key])) {
1271 return true;
1272 }
1273 }
1274
1275 return false;
1276 }
1277
1278 /**
1279 * Check if person settings have changed
1280 *
1281 * @since 1.0.0
1282 *
1283 * @param array $settings Updated settings
1284 * @return bool True if person settings changed
1285 */
1286 private function has_person_settings_changed(array $settings): bool {
1287 $person_keys = ['person_name', 'person_image', 'person_job_title', 'person_description'];
1288
1289 foreach ($person_keys as $key) {
1290 if (isset($settings[$key])) {
1291 return true;
1292 }
1293 }
1294
1295 return false;
1296 }
1297
1298 /**
1299 * Auto-detect appropriate schema types for context
1300 *
1301 * @since 1.0.0
1302 *
1303 * @param string $context_type Context type
1304 * @param int|null $context_id Context ID
1305 * @return array Detected schema types
1306 */
1307 private function auto_detect_schema_types(string $context_type, ?int $context_id): array {
1308 $detected_types = [];
1309
1310 switch ($context_type) {
1311 case 'site':
1312 $detected_types = ['Organization'];
1313 // Check if it's a local business
1314 if ($this->is_local_business()) {
1315 $detected_types[] = 'LocalBusiness';
1316 }
1317 break;
1318 case 'post':
1319 $detected_types = ['Article'];
1320 // Check content type for specific article types
1321 if ($context_id) {
1322 $post = get_post($context_id);
1323 if ($post && $this->is_how_to_content($post->post_content)) {
1324 $detected_types[] = 'HowTo';
1325 }
1326 }
1327 break;
1328 case 'page':
1329 $detected_types = ['Article'];
1330 if ($context_id) {
1331 $page = get_post($context_id);
1332 if ($page && $this->is_faq_content($page->post_content)) {
1333 $detected_types[] = 'FAQPage';
1334 }
1335 }
1336 break;
1337 case 'product':
1338 $detected_types = ['Product'];
1339 break;
1340 }
1341
1342 return $detected_types;
1343 }
1344
1345 /**
1346 * Prepare content data for Schema_Builder
1347 *
1348 * @since 1.0.0
1349 *
1350 * @param string $context_type Context type
1351 * @param int|null $context_id Context ID
1352 * @param array $content_analysis Content analysis data
1353 * @param array $optimization_data Optimization data
1354 * @param array $options Generation options (may contain custom content_data)
1355 * @return array Prepared content data for schema generation
1356 */
1357 private function prepare_content_data_for_generator(string $context_type, ?int $context_id, array $content_analysis, array $optimization_data, array $options = []): array {
1358 $content_data = [];
1359
1360 // Handle different context types
1361 if ($context_type === 'site') {
1362 // Site-level data
1363 $content_data = [
1364 'title' => get_bloginfo('name'),
1365 'url' => home_url(),
1366 'excerpt' => get_bloginfo('description'),
1367 'content' => get_bloginfo('description'),
1368 'business_data' => $this->get_business_data_from_local_seo(),
1369 'site_data' => $this->get_site_data_for_schema(),
1370 'social_data' => $this->get_social_data_for_schema()
1371 ];
1372 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1373 // Post/page/product data
1374 $post = get_post($context_id);
1375 if ($post) {
1376 $content_data = [
1377 'title' => $post->post_title,
1378 'url' => get_permalink($post->ID),
1379 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
1380 'content' => $post->post_content,
1381 'author' => [
1382 'name' => get_the_author_meta('display_name', $post->post_author),
1383 'url' => get_author_posts_url($post->post_author)
1384 ],
1385 'date' => $post->post_date,
1386 'modified' => $post->post_modified,
1387 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1388 'focus_keywords' => Focus_Keywords::get($post->ID),
1389 'business_data' => $this->get_business_data_from_local_seo(),
1390 'site_data' => $this->get_site_data_for_schema(),
1391 'social_data' => $this->get_social_data_for_schema()
1392 ];
1393
1394 // Override with custom content data if provided (for metabox usage)
1395 if (!empty($options['content_data'])) {
1396 $custom_data = $options['content_data'];
1397
1398 // Override title if provided and not empty
1399 if (!empty($custom_data['title'])) {
1400 $content_data['title'] = $custom_data['title'];
1401 }
1402
1403 // Override excerpt/description if provided and not empty
1404 if (!empty($custom_data['description'])) {
1405 $content_data['excerpt'] = $custom_data['description'];
1406 }
1407
1408 // Override content if provided and not empty
1409 if (!empty($custom_data['content'])) {
1410 $content_data['content'] = $custom_data['content'];
1411 }
1412
1413 // Override URL if provided and not empty, but ensure it's the post permalink, not admin URL
1414 if (!empty($custom_data['post_url'])) {
1415 // If the URL is an admin edit URL, convert it to the post permalink
1416 if (strpos($custom_data['post_url'], 'wp-admin/post.php') !== false && $context_id) {
1417 $content_data['url'] = get_permalink($context_id);
1418 } else {
1419 $content_data['url'] = $custom_data['post_url'];
1420 }
1421 }
1422
1423 // Add focus keyword(s) if provided
1424 if (!empty($custom_data['focus_keywords']) && is_array($custom_data['focus_keywords'])) {
1425 $content_data['focus_keywords'] = $custom_data['focus_keywords'];
1426 }
1427 if (!empty($custom_data['focus_keyword'])) {
1428 $content_data['focus_keyword'] = $custom_data['focus_keyword'];
1429 }
1430
1431 // Add word count if provided (from frontend calculation)
1432 if (!empty($custom_data['word_count'])) {
1433 $content_data['word_count'] = (int) $custom_data['word_count'];
1434 }
1435
1436 // CRITICAL: Override site_data fields that take precedence in schema builder
1437 // The schema builder checks site_data first, so we need to clear these
1438 // to ensure our custom data is used instead
1439 if (isset($content_data['site_data'])) {
1440 // Clear site-level article settings so custom data takes precedence
1441 unset($content_data['site_data']['article_headline']);
1442 unset($content_data['site_data']['article_description']);
1443 unset($content_data['site_data']['article_author']);
1444 }
1445 }
1446
1447 // Merge schema form data into site_data if provided (for content-specific schemas)
1448 if (!empty($options['schema_form_data'])) {
1449 $form_data = $options['schema_form_data'];
1450
1451 // Ensure site_data exists
1452 if (!isset($content_data['site_data'])) {
1453 $content_data['site_data'] = [];
1454 }
1455
1456 // Merge form data into site_data so schema builder can access it
1457 $content_data['site_data'] = array_merge($content_data['site_data'], $form_data);
1458 }
1459
1460 // Simplified: Content analysis moved to separate services
1461 // Word count and reading time handled by Schema_Builder directly from content
1462 }
1463 }
1464
1465 return $content_data;
1466 }
1467
1468 /**
1469 * Store schema data in database
1470 *
1471 * @since 1.0.0
1472 *
1473 * @param string $context_type Context type
1474 * @param int|null $context_id Context ID
1475 * @param array $generation Generation results
1476 * @return bool Success status
1477 */
1478 private function store_schema_data(string $context_type, ?int $context_id, array $generation): bool {
1479 global $wpdb;
1480
1481 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1482
1483 // First, delete all existing schemas for this context to ensure clean storage
1484 $this->delete_existing_schemas($context_type, $context_id);
1485
1486 foreach ($generation['generated_schemas'] as $schema_type => $schema_data) {
1487 // Prepare schema data with validation status embedded
1488 $schema_data_with_validation = $schema_data;
1489 $schema_data_with_validation['_validation'] = [
1490 'is_valid' => $generation['validation_results'][$schema_type]['is_valid'],
1491 'errors' => $generation['validation_results'][$schema_type]['errors'] ?? [],
1492 'warnings' => $generation['validation_results'][$schema_type]['warnings'] ?? [],
1493 'score' => $generation['validation_results'][$schema_type]['validation_score'] ?? 0
1494 ];
1495
1496 $data = [
1497 'context_type' => $context_type,
1498 'context_id' => $context_id,
1499 'schema_type' => $schema_type,
1500 'schema_data' => wp_json_encode($schema_data_with_validation),
1501 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid',
1502 'is_active' => $generation['deployment_ready'] ? 1 : 0
1503 ];
1504
1505 // Insert new schema (existing ones were already deleted)
1506 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
1507 $wpdb->insert($table_name, $data);
1508 }
1509
1510 // CACHE INVALIDATION: Clear cache after storing new schema data
1511 if ($this->cache_manager) {
1512 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
1513 }
1514
1515 return true;
1516 }
1517
1518 /**
1519 * Calculate validation score
1520 *
1521 * @since 1.0.0
1522 *
1523 * @param array $validation Validation results
1524 * @return int Score (0-100)
1525 */
1526 private function calculate_validation_score(array $validation): int {
1527 $score = 100;
1528 $score -= count($validation['errors'] ?? []) * 20;
1529 $score -= count($validation['warnings'] ?? []) * 10;
1530 $score -= count($validation['suggestions'] ?? []) * 5;
1531
1532 return max(0, (int) round($score));
1533 }
1534
1535 /**
1536 * Simple implementations for helper methods referenced in the main functions
1537 * These would be enhanced with more sophisticated algorithms in production
1538 */
1539
1540 // Removed complex AI integration methods - moved to separate services
1541 // Schema management focuses on core structured data generation
1542 private function generate_rich_snippets_preview(array $schema_data, string $schema_type): array {
1543 return [
1544 'preview_type' => $schema_type,
1545 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title',
1546 'description' => $schema_data['description'] ?? 'Description',
1547 'image' => $schema_data['image']['url'] ?? $schema_data['image'] ?? null,
1548 'additional_info' => $this->extract_additional_info($schema_data, $schema_type)
1549 ];
1550 }
1551
1552 private function extract_additional_info(array $schema_data, string $schema_type): array {
1553 $info = [];
1554
1555 switch ($schema_type) {
1556 case 'Article':
1557 if (isset($schema_data['author']['name'])) {
1558 $info['author'] = $schema_data['author']['name'];
1559 }
1560 if (isset($schema_data['datePublished'])) {
1561 $timestamp = strtotime($schema_data['datePublished']);
1562 if ($timestamp !== false) {
1563 $info['date'] = gmdate('M j, Y', $timestamp);
1564 }
1565 }
1566 break;
1567 case 'Product':
1568 if (isset($schema_data['offers']['price'])) {
1569 $info['price'] = $schema_data['offers']['priceCurrency'] . $schema_data['offers']['price'];
1570 }
1571 if (isset($schema_data['brand']['name'])) {
1572 $info['brand'] = $schema_data['brand']['name'];
1573 }
1574 break;
1575 }
1576
1577 return $info;
1578 }
1579
1580 private function generate_schema_optimization_recommendations(array $generated_schemas, array $validation_results): array {
1581 $recommendations = [];
1582
1583 foreach ($validation_results as $schema_type => $validation) {
1584 if (!$validation['is_valid']) {
1585 $recommendations[] = [
1586 'type' => 'validation_error',
1587 'schema_type' => $schema_type,
1588 'priority' => 'high',
1589 'message' => "Schema validation failed for {$schema_type}",
1590 'action' => 'Fix validation errors before deployment'
1591 ];
1592 }
1593
1594 if (!empty($validation['warnings'])) {
1595 // Extract missing properties from warnings
1596 $missing_properties = [];
1597 foreach ($validation['warnings'] as $warning) {
1598 if (strpos($warning, 'Missing recommended property:') === 0) {
1599 $property = trim(str_replace('Missing recommended property:', '', $warning));
1600 $missing_properties[] = $property;
1601 }
1602 }
1603
1604 if (!empty($missing_properties)) {
1605 $properties_list = implode(', ', $missing_properties);
1606 $recommendations[] = [
1607 'type' => 'missing_properties',
1608 'schema_type' => $schema_type,
1609 'priority' => 'medium',
1610 'message' => "Missing recommended properties for {$schema_type}: {$properties_list}",
1611 'action' => 'Add these properties to improve rich snippets eligibility'
1612 ];
1613 } else {
1614 $recommendations[] = [
1615 'type' => 'missing_properties',
1616 'schema_type' => $schema_type,
1617 'priority' => 'medium',
1618 'message' => "Missing recommended properties for {$schema_type}",
1619 'action' => 'Add recommended properties to improve rich snippets eligibility'
1620 ];
1621 }
1622 }
1623 }
1624
1625 return $recommendations;
1626 }
1627
1628 private function check_deployment_readiness(array $validation_results): bool {
1629 foreach ($validation_results as $validation) {
1630 if (!$validation['is_valid']) {
1631 return false;
1632 }
1633 }
1634 return true;
1635 }
1636
1637 // Content detection helper methods
1638 private function is_local_business(): bool {
1639 // Simple check - would be enhanced with actual business detection
1640 $description = get_bloginfo('description');
1641 $local_keywords = ['restaurant', 'shop', 'store', 'clinic', 'office', 'service'];
1642
1643 foreach ($local_keywords as $keyword) {
1644 if (stripos($description, $keyword) !== false) {
1645 return true;
1646 }
1647 }
1648
1649 return false;
1650 }
1651
1652 private function is_how_to_content(string $content): bool {
1653 $how_to_keywords = ['step', 'how to', 'tutorial', 'guide', 'instructions'];
1654 $content_lower = strtolower($content);
1655
1656 foreach ($how_to_keywords as $keyword) {
1657 if (stripos($content_lower, $keyword) !== false) {
1658 return true;
1659 }
1660 }
1661
1662 return false;
1663 }
1664
1665 private function is_faq_content(string $content): bool {
1666 $faq_keywords = ['faq', 'frequently asked', 'questions', 'q:', 'a:'];
1667 $content_lower = strtolower($content);
1668
1669 foreach ($faq_keywords as $keyword) {
1670 if (stripos($content_lower, $keyword) !== false) {
1671 return true;
1672 }
1673 }
1674
1675 return false;
1676 }
1677
1678 private function determine_deployment_method(array $options): string {
1679 // Always use JSON-LD as it's the only supported method
1680 return 'json_ld';
1681 }
1682
1683 private function deploy_single_schema(array $schema, string $schema_type, string $method, string $context_type, ?int $context_id): array {
1684 global $wpdb;
1685
1686 // Use existing seo_schema table
1687 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1688
1689 $deployment_data = [
1690 'context_type' => $context_type,
1691 'context_id' => $context_id,
1692 'schema_type' => $schema_type,
1693 'schema_data' => wp_json_encode($schema),
1694 'validation_status' => 'deployed',
1695 'is_active' => 1
1696 ];
1697
1698 // Check if schema already exists for this context and type
1699 if (null === $context_id) {
1700 // Handle NULL context_id case
1701 // 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
1702 $sql = sprintf(
1703 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id IS NULL AND schema_type = %%s',
1704 $table_name
1705 );
1706 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access
1707 $existing = $wpdb->get_var(
1708 $wpdb->prepare(
1709 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1710 $sql,
1711 $context_type,
1712 $schema_type
1713 )
1714 );
1715 } else {
1716 // Handle regular context_id case
1717 // 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
1718 $sql = sprintf(
1719 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id = %%d AND schema_type = %%s',
1720 $table_name
1721 );
1722 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access
1723 $existing = $wpdb->get_var(
1724 $wpdb->prepare(
1725 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1726 $sql,
1727 $context_type,
1728 $context_id,
1729 $schema_type
1730 )
1731 );
1732 }
1733
1734 if ($existing) {
1735 // Update existing deployment
1736 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema update requires direct database access
1737 $result = $wpdb->update(
1738 $table_name,
1739 [
1740 'schema_data' => wp_json_encode($schema),
1741 'validation_status' => 'deployed',
1742 'is_active' => 1,
1743 'updated_at' => current_time('mysql')
1744 ],
1745 ['schema_id' => $existing],
1746 ['%s', '%s', '%d', '%s'],
1747 ['%d']
1748 );
1749
1750 } else {
1751 // Insert new deployment
1752 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
1753 $result = $wpdb->insert(
1754 $table_name,
1755 $deployment_data,
1756 ['%s', '%d', '%s', '%s', '%s', '%d']
1757 );
1758
1759 }
1760
1761 return [
1762 'deployed' => $result !== false,
1763 'method' => $method,
1764 'schema_type' => $schema_type,
1765 'schema_id' => $existing ?: $wpdb->insert_id
1766 ];
1767 }
1768
1769 /**
1770 * Get deployed schemas for frontend integration
1771 *
1772 * PERFORMANCE OPTIMIZED: This method now uses:
1773 * 1. Schema caching layer (90% reduction in database queries)
1774 * 2. Window function approach instead of correlated subquery (80-90% query performance improvement)
1775 * 3. Composite index: idx_context_schema_active
1776 *
1777 * @since 1.0.0
1778 *
1779 * @param string $context_type Context type
1780 * @param int|null $context_id Context ID
1781 * @return array Deployed schemas for current context
1782 */
1783 public function get_deployed_schemas(string $context_type = 'site', ?int $context_id = null): array {
1784 // CACHE LAYER: Check cache first for immediate 90% performance improvement
1785 if ($this->cache_manager) {
1786 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
1787 $cached_data = $this->cache_manager->get($cache_key);
1788
1789 if ($cached_data !== null) {
1790 // CACHE FIX: Extract actual data from cache wrapper
1791 return isset($cached_data['data']) ? $cached_data['data'] : $cached_data;
1792 }
1793 }
1794
1795 global $wpdb;
1796
1797 // Use existing seo_schema table
1798 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1799
1800 // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery
1801 // This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC)
1802 if (null === $context_id) {
1803 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1804 $sql = sprintf(
1805 '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',
1806 $table_name
1807 );
1808 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1809 $deployed_schemas = $wpdb->get_results(
1810 $wpdb->prepare(
1811 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1812 $sql,
1813 $context_type
1814 ),
1815 ARRAY_A
1816 );
1817 } else {
1818 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1819 $sql = sprintf(
1820 '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',
1821 $table_name
1822 );
1823 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
1824 $deployed_schemas = $wpdb->get_results(
1825 $wpdb->prepare(
1826 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1827 $sql,
1828 $context_type,
1829 $context_id
1830 ),
1831 ARRAY_A
1832 );
1833 }
1834
1835 // Deliberately no early return on an empty result: it has to reach the
1836 // cache write below. Most URLs have no deployed schema, so gating the
1837 // write on a non-empty result made the majority of front-end requests
1838 // permanent cache misses, re-running a ROW_NUMBER() OVER (PARTITION BY
1839 // ...) query with two filesorts on every pageview (#392).
1840 $deployed_schemas = $deployed_schemas ?: [];
1841
1842 // Process schemas for return
1843 $processed_schemas = [];
1844 foreach ($deployed_schemas as $deployed_schema) {
1845 $schema_data = json_decode($deployed_schema['schema_data'], true);
1846 $schema_type = $deployed_schema['schema_type'];
1847
1848 if (!empty($schema_data)) {
1849 // Remove internal validation metadata before frontend output
1850 if (isset($schema_data['_validation'])) {
1851 unset($schema_data['_validation']);
1852 }
1853
1854 $processed_schemas[$schema_type] = [
1855 'data' => $schema_data,
1856 'method' => 'json_ld', // Default method
1857 'type' => $schema_type
1858 ];
1859 }
1860 }
1861
1862 // CACHE LAYER: Store result in cache for future requests — including
1863 // an empty one. Cache_Manager::set() wraps the payload in a metadata
1864 // envelope, so an empty result is still stored as a truthy value and
1865 // reads back as a hit rather than a miss (#392).
1866 if ($this->cache_manager) {
1867 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
1868 $this->cache_manager->set($cache_key, $processed_schemas);
1869 }
1870
1871 return $processed_schemas;
1872 }
1873
1874 /**
1875 * Clean up duplicate schemas in database
1876 *
1877 * @since 1.0.0
1878 *
1879 * @param string $context_type Context type
1880 * @param int|null $context_id Context ID
1881 * @return int Number of duplicate schemas removed
1882 */
1883 public function cleanup_duplicate_schemas(string $context_type = 'site', ?int $context_id = null): int {
1884 global $wpdb;
1885
1886 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1887
1888 if (null === $context_id) {
1889 // Clean up duplicates for NULL context_id
1890 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
1891 $sql = sprintf(
1892 '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',
1893 $table_name,
1894 $table_name
1895 );
1896 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
1897 $deleted = $wpdb->query(
1898 $wpdb->prepare(
1899 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1900 $sql,
1901 $context_type,
1902 $context_type
1903 )
1904 );
1905 } else {
1906 // Clean up duplicates for specific context_id
1907 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
1908 $sql = sprintf(
1909 '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',
1910 $table_name,
1911 $table_name
1912 );
1913 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
1914 $deleted = $wpdb->query(
1915 $wpdb->prepare(
1916 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1917 $sql,
1918 $context_type,
1919 $context_id,
1920 $context_type,
1921 $context_id
1922 )
1923 );
1924 }
1925
1926 return $deleted ?: 0;
1927 }
1928 /**
1929 * Delete all existing schemas for a context before storing new ones
1930 *
1931 * @since 1.0.0
1932 *
1933 * @param string $context_type Context type
1934 * @param int|null $context_id Context ID
1935 * @return int Number of schemas deleted
1936 */
1937 private function delete_existing_schemas(string $context_type, ?int $context_id): int {
1938 global $wpdb;
1939
1940 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1941
1942 if (null === $context_id) {
1943 // Delete all schemas for NULL context_id
1944 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1945 $sql = sprintf(
1946 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL',
1947 $table_name
1948 );
1949 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1950 $deleted = $wpdb->query(
1951 $wpdb->prepare(
1952 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1953 $sql,
1954 $context_type
1955 )
1956 );
1957 } else {
1958 // Delete all schemas for specific context_id
1959 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1960 $sql = sprintf(
1961 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d',
1962 $table_name
1963 );
1964 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
1965 $deleted = $wpdb->query(
1966 $wpdb->prepare(
1967 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1968 $sql,
1969 $context_type,
1970 $context_id
1971 )
1972 );
1973 }
1974
1975 return $deleted ?: 0;
1976 }
1977
1978 /**
1979 * Get business data from Site Identity Local settings
1980 *
1981 * @return array
1982 */
1983 private function get_business_data_from_local_seo(): array {
1984 // Get Site Identity settings which include Local SEO data
1985 $site_identity_settings = get_option('thinkrank_site_identity_settings', []);
1986
1987 return [
1988 'business_name' => $site_identity_settings['business_name'] ?? '',
1989 'business_address' => $site_identity_settings['business_address'] ?? '',
1990 'business_city' => $site_identity_settings['business_city'] ?? '',
1991 'business_state' => $site_identity_settings['business_state'] ?? '',
1992 'business_postal_code' => $site_identity_settings['business_postal_code'] ?? '',
1993 'business_country' => $site_identity_settings['business_country'] ?? '',
1994 'business_phone' => $site_identity_settings['business_phone'] ?? '',
1995 'business_email' => $site_identity_settings['business_email'] ?? '',
1996 'business_hours' => $site_identity_settings['business_hours'] ?? [],
1997 'business_type' => $site_identity_settings['business_type'] ?? 'LocalBusiness'
1998 ];
1999 }
2000 public function get_settings(string $context_type, ?int $context_id = null): array {
2001 // Get base settings from parent
2002 $settings = parent::get_settings($context_type, $context_id);
2003
2004 // For site context, automatically include Site Identity data
2005 if ($context_type === 'site') {
2006 // Get Site Identity settings from the Site Identity Manager
2007 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
2008 $site_identity_settings = $site_identity_manager->get_settings('site', null);
2009
2010 // Include Site Identity assets if not already set in Schema Manager
2011 if (empty($settings['logo_url']) && !empty($site_identity_settings['logo_url'])) {
2012 $settings['logo_url'] = $site_identity_settings['logo_url'];
2013 }
2014 if (empty($settings['favicon_url']) && !empty($site_identity_settings['favicon_url'])) {
2015 $settings['favicon_url'] = $site_identity_settings['favicon_url'];
2016 }
2017 if (empty($settings['apple_touch_icon_url']) && !empty($site_identity_settings['apple_touch_icon_url'])) {
2018 $settings['apple_touch_icon_url'] = $site_identity_settings['apple_touch_icon_url'];
2019 }
2020
2021 // Include Site Identity organization data if not already set in Schema Manager
2022 if (empty($settings['organization_name']) && !empty($site_identity_settings['site_name'])) {
2023 $settings['organization_name'] = $site_identity_settings['site_name'];
2024 }
2025 if (empty($settings['organization_url']) && !empty($site_identity_settings['site_url'])) {
2026 $settings['organization_url'] = $site_identity_settings['site_url'];
2027 }
2028 if (empty($settings['organization_description']) && !empty($site_identity_settings['site_description'])) {
2029 $settings['organization_description'] = $site_identity_settings['site_description'];
2030 }
2031 }
2032
2033 return $settings;
2034 }
2035
2036 /**
2037 * Get site data for rich schema generation
2038 *
2039 * @return array
2040 */
2041 private function get_site_data_for_schema(): array {
2042 // Get Schema Manager's own settings first (highest priority)
2043 $schema_settings = $this->get_settings('site', null);
2044
2045 // Get Site Identity settings for additional data
2046 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
2047 $site_identity_settings = $site_identity_manager->get_settings('site', null);
2048
2049 return [
2050 'site_name' => get_bloginfo('name'),
2051 'site_description' => get_bloginfo('description'),
2052 'site_url' => home_url(),
2053 'admin_email' => get_option('admin_email'),
2054 'language' => get_locale(),
2055 'timezone' => get_option('timezone_string'),
2056 'founded_date' => $site_identity_settings['founded_date'] ?? '',
2057 'founder_name' => $site_identity_settings['founder_name'] ?? '',
2058 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
2059 // Site Identity assets
2060 'logo_url' => $site_identity_settings['logo_url'] ?? '',
2061 'favicon_url' => $site_identity_settings['favicon_url'] ?? '',
2062 // Schema Manager organization settings (highest priority)
2063 'organization_name' => $schema_settings['organization_name'] ?? '',
2064 'organization_description' => $schema_settings['organization_description'] ?? '',
2065 'organization_url' => $schema_settings['organization_url'] ?? '',
2066
2067 // Removed post/page-specific schema settings (Product, Event, Article, Software Application)
2068 // These are now handled only at the post/page level via metabox
2069
2070 // Person schema settings (site-wide)
2071 'person_name' => $schema_settings['person_name'] ?? '',
2072 'person_job_title' => $schema_settings['person_job_title'] ?? '',
2073 'person_description' => $schema_settings['person_description'] ?? '',
2074 'person_image' => $schema_settings['person_image'] ?? '',
2075 'person_url' => $schema_settings['person_url'] ?? '',
2076 'person_email' => $schema_settings['person_email'] ?? '',
2077 'person_telephone' => $schema_settings['person_telephone'] ?? '',
2078 'person_address' => $schema_settings['person_address'] ?? '',
2079 'person_birth_date' => $schema_settings['person_birth_date'] ?? '',
2080 'person_nationality' => $schema_settings['person_nationality'] ?? '',
2081 'person_works_for' => $schema_settings['person_works_for'] ?? '',
2082 'person_same_as' => $schema_settings['person_same_as'] ?? [],
2083
2084 // Website schema settings (site-wide)
2085 'website_name' => $schema_settings['website_name'] ?? '',
2086 'website_url' => $schema_settings['website_url'] ?? '',
2087 'website_description' => $schema_settings['website_description'] ?? '',
2088 'website_author' => $schema_settings['website_author'] ?? '',
2089
2090 'organization_logo' => $schema_settings['organization_logo'] ?? '',
2091 // Social media links (sameAs)
2092 'organization_social_facebook' => $schema_settings['organization_social_facebook'] ?? '',
2093 'organization_social_twitter' => $schema_settings['organization_social_twitter'] ?? '',
2094 'organization_social_linkedin' => $schema_settings['organization_social_linkedin'] ?? '',
2095 'organization_social_instagram' => $schema_settings['organization_social_instagram'] ?? '',
2096 'organization_social_youtube' => $schema_settings['organization_social_youtube'] ?? '',
2097 'organization_social_pinterest' => $schema_settings['organization_social_pinterest'] ?? '',
2098 'organization_social_whatsapp' => $schema_settings['organization_social_whatsapp'] ?? '',
2099 'organization_social_telegram' => $schema_settings['organization_social_telegram'] ?? '',
2100 // Contact point information
2101 'organization_contact_type' => $schema_settings['organization_contact_type'] ?? 'customer service',
2102 'organization_contact_phone' => $schema_settings['organization_contact_phone'] ?? '',
2103 'organization_contact_email' => $schema_settings['organization_contact_email'] ?? '',
2104 'organization_contact_hours' => $schema_settings['organization_contact_hours'] ?? '',
2105 // LocalBusiness specific fields
2106 'business_price_range' => $schema_settings['business_price_range'] ?? '',
2107 'business_geo_latitude' => $schema_settings['business_geo_latitude'] ?? '',
2108 'business_geo_longitude' => $schema_settings['business_geo_longitude'] ?? '',
2109 'business_opening_hours' => $schema_settings['business_opening_hours'] ?? []
2110 ];
2111 }
2112
2113 /**
2114 * Get social media data for schema generation
2115 *
2116 * @return array
2117 */
2118 private function get_social_data_for_schema(): array {
2119 // Get Social Media settings
2120 $social_settings = get_option('thinkrank_social_media_settings', []);
2121
2122 $social_profiles = [];
2123
2124 // Common social platforms
2125 $platforms = ['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'tiktok', 'pinterest'];
2126
2127 foreach ($platforms as $platform) {
2128 $url = $social_settings["{$platform}_url"] ?? '';
2129 if (!empty($url)) {
2130 $social_profiles[] = $url;
2131 }
2132 }
2133
2134 return [
2135 'social_profiles' => $social_profiles,
2136 'social_settings' => $social_settings
2137 ];
2138 }
2139 }
2140