PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 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.10.0, at includes/seo/class-schema-management-system.php

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