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

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