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

2,804 lines 115.9 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 && self::should_auto_deploy($settings, $this->get_settings($context_type, $context_id))) {
1330 $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1331 }
1332
1333 return $success;
1334 }
1335
1336
1337 /**
1338 * Whether a save should redeploy the schema it changed.
1339 *
1340 * `auto_deploy` is a stored setting (on by default), not something a save
1341 * restates. Reading it off the incoming patch meant a partial save — which
1342 * is what the admin screen sends, one field at a time — skipped
1343 * auto-deploy on a site that had it switched on, and the deployed snapshot
1344 * the front end serves kept the name, logo and sameAs it was deployed
1345 * with, however often the user saved (#904, the gate #12 left in place).
1346 *
1347 * A patch that does carry the key still wins, so a caller can deploy or
1348 * hold deliberately.
1349 *
1350 * @param array $patch Settings being saved
1351 * @param array $stored Settings as stored, after the save
1352 * @return bool
1353 */
1354 public static function should_auto_deploy(array $patch, array $stored): bool {
1355 if (array_key_exists('auto_deploy', $patch)) {
1356 return !empty($patch['auto_deploy']);
1357 }
1358
1359 return !empty($stored['auto_deploy']);
1360 }
1361
1362 /**
1363 * Auto-deploy schema when settings change
1364 *
1365 * Automatically regenerates and deploys schema markup when organization or other
1366 * schema settings are modified, ensuring the frontend output stays in sync.
1367 *
1368 * @since 1.0.0
1369 *
1370 * @param string $context_type Context type
1371 * @param int|null $context_id Context ID
1372 * @param array $settings Updated settings
1373 * @return void
1374 */
1375 private function auto_deploy_schema_on_settings_change(string $context_type, ?int $context_id, array $settings): void {
1376 // Determine which schema types need to be regenerated based on changed settings
1377 $schema_types_to_regenerate = [];
1378
1379 // Organization schema - regenerate if organization settings changed
1380 if ($this->has_organization_settings_changed($settings)) {
1381 $schema_types_to_regenerate[] = 'Organization';
1382 }
1383
1384 // Website schema - regenerate if website settings changed. The type is
1385 // registered as 'WebSite' (capital S) in Schema_Factory / $schema_types;
1386 // using 'Website' here made generate_schema_markup() silently skip it.
1387 if ($this->has_website_settings_changed($settings)) {
1388 $schema_types_to_regenerate[] = 'WebSite';
1389 }
1390
1391 // LocalBusiness schema - regenerate if business settings changed
1392 if ($this->has_business_settings_changed($settings)) {
1393 $schema_types_to_regenerate[] = 'LocalBusiness';
1394 }
1395
1396 // Person schema - regenerate if person settings changed
1397 if ($this->has_person_settings_changed($settings)) {
1398 $schema_types_to_regenerate[] = 'Person';
1399 }
1400
1401 // Honour the user's Schema Types selection. Without this the payload
1402 // shape alone decided what shipped, so every save deployed all four
1403 // types — including ones the user had explicitly deselected (#461).
1404 // An empty selection means "auto", so only filter when one is set.
1405 $enabled_types = $settings['enabled_schema_types'] ?? $this->get_settings($context_type, $context_id)['enabled_schema_types'] ?? [];
1406
1407 if (!empty($enabled_types) && is_array($enabled_types)) {
1408 $schema_types_to_regenerate = array_values(
1409 array_intersect($schema_types_to_regenerate, $enabled_types)
1410 );
1411 }
1412
1413 // Types that were deployed but are no longer wanted must come back off
1414 // the page — deployment used to be additive-only (#464).
1415 $this->retire_unselected_schema_types($context_type, $context_id, $enabled_types);
1416
1417 // If no schema types need regeneration, return early
1418 if (empty($schema_types_to_regenerate)) {
1419 return;
1420 }
1421
1422 // Generate every affected type in ONE call. Generating them one at a
1423 // time re-entered store_schema_data() per type, and each pass replaced
1424 // the rows written by the previous one, so only the last type survived
1425 // (#454). One batch also means one delete and one cache flush.
1426 try {
1427 $generation_result = $this->generate_schema_markup(
1428 $context_type,
1429 $context_id,
1430 $schema_types_to_regenerate
1431 );
1432
1433 $deployable = [];
1434 foreach ($schema_types_to_regenerate as $schema_type) {
1435 // Only deploy what validated — see #470.
1436 if (!empty($generation_result['generated_schemas'][$schema_type])
1437 && !empty($generation_result['validation_results'][$schema_type]['is_valid'])
1438 ) {
1439 $deployable[$schema_type] = $generation_result['generated_schemas'][$schema_type];
1440 }
1441 }
1442
1443 if (!empty($deployable)) {
1444 $this->deploy_schema_markup($context_type, $context_id, $deployable);
1445 }
1446 } catch (\Exception $e) {
1447 // Log error but don't fail the settings save
1448 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1449 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1450 error_log('ThinkRank: Auto-deploy failed for ' . implode(', ', $schema_types_to_regenerate) . ': ' . $e->getMessage());
1451 }
1452 }
1453 }
1454
1455 /**
1456 * Check if organization settings have changed
1457 *
1458 * @since 1.0.0
1459 *
1460 * @param array $settings Updated settings
1461 * @return bool True if organization settings changed
1462 */
1463 private function has_organization_settings_changed(array $settings): bool {
1464 $org_keys = [
1465 'organization_name', 'organization_type', 'organization_logo', 'organization_url',
1466 'organization_description', 'organization_social_facebook', 'organization_social_twitter',
1467 'organization_social_linkedin', 'organization_social_instagram', 'organization_social_youtube',
1468 'organization_social_pinterest', 'organization_social_whatsapp', 'organization_social_telegram',
1469 'organization_contact_type', 'organization_contact_phone', 'organization_contact_email',
1470 'organization_contact_hours'
1471 ];
1472
1473 foreach ($org_keys as $key) {
1474 if (isset($settings[$key])) {
1475 return true;
1476 }
1477 }
1478
1479 return false;
1480 }
1481
1482 /**
1483 * Check if website settings have changed
1484 *
1485 * @since 1.0.0
1486 *
1487 * @param array $settings Updated settings
1488 * @return bool True if website settings changed
1489 */
1490 private function has_website_settings_changed(array $settings): bool {
1491 // These are the keys the Website tab actually stores. It previously
1492 // looked for site_name/site_description/site_url, which belong to Site
1493 // Identity and never appear in a schema settings payload — so WebSite
1494 // schema never auto-deployed no matter what was edited (#455).
1495 $website_keys = [
1496 'website_name', 'website_url', 'website_description', 'website_author',
1497 ];
1498
1499 foreach ($website_keys as $key) {
1500 if (isset($settings[$key])) {
1501 return true;
1502 }
1503 }
1504
1505 return false;
1506 }
1507
1508 /**
1509 * Check if business settings have changed
1510 *
1511 * @since 1.0.0
1512 *
1513 * @param array $settings Updated settings
1514 * @return bool True if business settings changed
1515 */
1516 private function has_business_settings_changed(array $settings): bool {
1517 // Only the keys this manager actually stores. business_name/address/
1518 // phone/hours live in the site_identity category and never reach a
1519 // schema settings save, so keying off them meant LocalBusiness never
1520 // auto-deployed (#455). Edits to those fields refresh LocalBusiness
1521 // through the Site Identity save path instead — see
1522 // refresh_schema_for_foreign_settings().
1523 $business_keys = [
1524 'enable_local_business',
1525 'business_price_range',
1526 'business_geo_latitude',
1527 'business_geo_longitude',
1528 'business_opening_hours',
1529 ];
1530
1531 foreach ($business_keys as $key) {
1532 if (isset($settings[$key])) {
1533 return true;
1534 }
1535 }
1536
1537 return false;
1538 }
1539
1540 /**
1541 * Check if person settings have changed
1542 *
1543 * @since 1.0.0
1544 *
1545 * @param array $settings Updated settings
1546 * @return bool True if person settings changed
1547 */
1548 private function has_person_settings_changed(array $settings): bool {
1549 $person_keys = ['person_name', 'person_image', 'person_job_title', 'person_description'];
1550
1551 foreach ($person_keys as $key) {
1552 if (isset($settings[$key])) {
1553 return true;
1554 }
1555 }
1556
1557 return false;
1558 }
1559
1560 /**
1561 * Auto-detect appropriate schema types for context
1562 *
1563 * @since 1.0.0
1564 *
1565 * @param string $context_type Context type
1566 * @param int|null $context_id Context ID
1567 * @return array Detected schema types
1568 */
1569 private function auto_detect_schema_types(string $context_type, ?int $context_id): array {
1570 $detected_types = [];
1571
1572 switch ($context_type) {
1573 case 'site':
1574 // The admin's Schema Types selection is the answer to "what
1575 // does this site need"; detection is only the fallback for an
1576 // install that has not chosen yet (#456).
1577 $settings = $this->get_settings($context_type, $context_id);
1578 $enabled = array_values(array_filter(
1579 array_map('strval', (array) ($settings['enabled_schema_types'] ?? [])),
1580 'strlen'
1581 ));
1582
1583 // Drop stale names the factory no longer registers rather than
1584 // handing them to the builder to silently skip.
1585 $enabled = array_values(array_filter(
1586 $enabled,
1587 fn($type) => isset($this->schema_types[$type])
1588 ));
1589
1590 if (!empty($enabled)) {
1591 $detected_types = $enabled;
1592 break;
1593 }
1594
1595 $detected_types = ['Organization'];
1596 // Check if it's a local business
1597 if ($this->is_local_business()) {
1598 $detected_types[] = 'LocalBusiness';
1599 }
1600 break;
1601 case 'post':
1602 $detected_types = ['Article'];
1603 // Check content type for specific article types
1604 if ($context_id) {
1605 $post = get_post($context_id);
1606 if ($post && $this->is_how_to_content($post->post_content)) {
1607 $detected_types[] = 'HowTo';
1608 }
1609 }
1610 break;
1611 case 'page':
1612 $detected_types = ['Article'];
1613 if ($context_id) {
1614 $page = get_post($context_id);
1615 if ($page && $this->is_faq_content($page->post_content)) {
1616 $detected_types[] = 'FAQPage';
1617 }
1618 }
1619 break;
1620 case 'product':
1621 $detected_types = ['Product'];
1622 break;
1623 }
1624
1625 return $detected_types;
1626 }
1627
1628 /**
1629 * Prepare content data for Schema_Builder
1630 *
1631 * @since 1.0.0
1632 *
1633 * @param string $context_type Context type
1634 * @param int|null $context_id Context ID
1635 * @param array $content_analysis Content analysis data
1636 * @param array $optimization_data Optimization data
1637 * @param array $options Generation options (may contain custom content_data)
1638 * @return array Prepared content data for schema generation
1639 */
1640 private function prepare_content_data_for_generator(string $context_type, ?int $context_id, array $content_analysis, array $optimization_data, array $options = []): array {
1641 $content_data = [];
1642
1643 // Handle different context types
1644 if ($context_type === 'site') {
1645 // Site-level data
1646 $content_data = [
1647 'title' => get_bloginfo('name'),
1648 'url' => home_url(),
1649 'excerpt' => get_bloginfo('description'),
1650 'content' => get_bloginfo('description'),
1651 'business_data' => $this->get_business_data_from_local_seo(),
1652 'site_data' => $this->get_site_data_for_schema(),
1653 'social_data' => $this->get_social_data_for_schema()
1654 ];
1655 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1656 // Post/page/product data
1657 $post = get_post($context_id);
1658 if ($post) {
1659 // Resolve the featured image's URL to its ID here, where the ID
1660 // is in hand, so the schema builder does not query for an
1661 // attachment it was just given (#847). Offered as a hint rather
1662 // than asserted: `post_thumbnail_url` can swap the URL for one
1663 // the featured image does not own.
1664 $thumbnail_url = get_the_post_thumbnail_url($post->ID, 'full');
1665
1666 if ($thumbnail_url) {
1667 Attachment_Lookup::id_from_url((string) $thumbnail_url, (int) get_post_thumbnail_id($post->ID));
1668 }
1669
1670 $content_data = [
1671 'title' => $post->post_title,
1672 'url' => get_permalink($post->ID),
1673 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30),
1674 'content' => $post->post_content,
1675 'author' => [
1676 'name' => get_the_author_meta('display_name', $post->post_author),
1677 'url' => get_author_posts_url($post->post_author)
1678 ],
1679 // ISO 8601 with offset. post_date/post_modified are raw
1680 // MySQL columns in site-local time with no timezone, which
1681 // Google rejects as "Invalid value in field datePublished"
1682 // and drops the Article rich result (#465).
1683 'date' => get_the_date('c', $post),
1684 'modified' => get_the_modified_date('c', $post),
1685 'image' => $thumbnail_url,
1686 'focus_keywords' => Focus_Keywords::get($post->ID),
1687 'business_data' => $this->get_business_data_from_local_seo(),
1688 'site_data' => $this->get_site_data_for_schema(),
1689 'social_data' => $this->get_social_data_for_schema()
1690 ];
1691
1692 // Override with custom content data if provided (for metabox usage)
1693 if (!empty($options['content_data'])) {
1694 $custom_data = $options['content_data'];
1695
1696 // Override title if provided and not empty
1697 if (!empty($custom_data['title'])) {
1698 $content_data['title'] = $custom_data['title'];
1699 }
1700
1701 // Override excerpt/description if provided and not empty
1702 if (!empty($custom_data['description'])) {
1703 $content_data['excerpt'] = $custom_data['description'];
1704 }
1705
1706 // Override content if provided and not empty
1707 if (!empty($custom_data['content'])) {
1708 $content_data['content'] = $custom_data['content'];
1709 }
1710
1711 // Override URL if provided and not empty, but ensure it's the post permalink, not admin URL
1712 if (!empty($custom_data['post_url'])) {
1713 // If the URL is an admin edit URL, convert it to the post permalink
1714 if (strpos($custom_data['post_url'], 'wp-admin/post.php') !== false && $context_id) {
1715 $content_data['url'] = get_permalink($context_id);
1716 } else {
1717 $content_data['url'] = $custom_data['post_url'];
1718 }
1719 }
1720
1721 // Add focus keyword(s) if provided
1722 if (!empty($custom_data['focus_keywords']) && is_array($custom_data['focus_keywords'])) {
1723 $content_data['focus_keywords'] = $custom_data['focus_keywords'];
1724 }
1725 if (!empty($custom_data['focus_keyword'])) {
1726 $content_data['focus_keyword'] = $custom_data['focus_keyword'];
1727 }
1728
1729 // Add word count if provided (from frontend calculation)
1730 if (!empty($custom_data['word_count'])) {
1731 $content_data['word_count'] = (int) $custom_data['word_count'];
1732 }
1733
1734 // CRITICAL: Override site_data fields that take precedence in schema builder
1735 // The schema builder checks site_data first, so we need to clear these
1736 // to ensure our custom data is used instead
1737 if (isset($content_data['site_data'])) {
1738 // Clear site-level article settings so custom data takes precedence
1739 unset($content_data['site_data']['article_headline']);
1740 unset($content_data['site_data']['article_description']);
1741 unset($content_data['site_data']['article_author']);
1742 }
1743 }
1744
1745 // Merge schema form data into site_data if provided (for content-specific schemas)
1746 if (!empty($options['schema_form_data'])) {
1747 $form_data = $options['schema_form_data'];
1748
1749 // Ensure site_data exists
1750 if (!isset($content_data['site_data'])) {
1751 $content_data['site_data'] = [];
1752 }
1753
1754 // Merge form data into site_data so schema builder can access it
1755 $content_data['site_data'] = array_merge($content_data['site_data'], $form_data);
1756 }
1757
1758 // Simplified: Content analysis moved to separate services
1759 // Word count and reading time handled by Schema_Builder directly from content
1760 }
1761 }
1762
1763 return $content_data;
1764 }
1765
1766 /**
1767 * Store schema data in database
1768 *
1769 * @since 1.0.0
1770 *
1771 * @param string $context_type Context type
1772 * @param int|null $context_id Context ID
1773 * @param array $generation Generation results
1774 * @return bool Success status
1775 */
1776 private function store_schema_data(string $context_type, ?int $context_id, array $generation): bool {
1777 global $wpdb;
1778
1779 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1780
1781 // Replace only the types in this batch. Clearing the whole context
1782 // destroyed types the caller never asked about — and callers do
1783 // regenerate a subset, one type at a time (#454).
1784 $generated_types = array_keys($generation['generated_schemas'] ?? []);
1785 if (empty($generated_types)) {
1786 return false;
1787 }
1788 $this->delete_existing_schemas($context_type, $context_id, $generated_types);
1789
1790 foreach ($generation['generated_schemas'] as $schema_type => $schema_data) {
1791 // Prepare schema data with validation status embedded
1792 $schema_data_with_validation = $schema_data;
1793 $schema_data_with_validation['_validation'] = [
1794 'is_valid' => $generation['validation_results'][$schema_type]['is_valid'],
1795 'errors' => $generation['validation_results'][$schema_type]['errors'] ?? [],
1796 'warnings' => $generation['validation_results'][$schema_type]['warnings'] ?? [],
1797 'score' => $generation['validation_results'][$schema_type]['validation_score'] ?? 0
1798 ];
1799
1800 $data = [
1801 'context_type' => $context_type,
1802 'context_id' => $context_id,
1803 'schema_type' => $schema_type,
1804 'schema_data' => wp_json_encode($schema_data_with_validation),
1805 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid',
1806 // Per-type, not batch-wide. deployment_ready is only true when
1807 // EVERY type in the batch validated, so one invalid type (a site
1808 // with no Business Info makes LocalBusiness invalid) deactivated
1809 // all the valid ones alongside it (#470).
1810 'is_active' => !empty($generation['validation_results'][$schema_type]['is_valid']) ? 1 : 0
1811 ];
1812
1813 // Insert new schema (existing ones were already deleted)
1814 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
1815 $wpdb->insert($table_name, $data);
1816 }
1817
1818 // CACHE INVALIDATION: Clear cache after storing new schema data
1819 if ($this->cache_manager) {
1820 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
1821 }
1822
1823 return true;
1824 }
1825
1826 /**
1827 * Calculate validation score
1828 *
1829 * @since 1.0.0
1830 *
1831 * @param array $validation Validation results
1832 * @return int Score (0-100)
1833 */
1834 private function calculate_validation_score(array $validation): int {
1835 $score = 100;
1836 $score -= count($validation['errors'] ?? []) * 20;
1837 $score -= count($validation['warnings'] ?? []) * 10;
1838 $score -= count($validation['suggestions'] ?? []) * 5;
1839
1840 return max(0, (int) round($score));
1841 }
1842
1843 /**
1844 * Simple implementations for helper methods referenced in the main functions
1845 * These would be enhanced with more sophisticated algorithms in production
1846 */
1847
1848 // Removed complex AI integration methods - moved to separate services
1849 // Schema management focuses on core structured data generation
1850 private function generate_rich_snippets_preview(array $schema_data, string $schema_type): array {
1851 return [
1852 'preview_type' => $schema_type,
1853 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title',
1854 'description' => $schema_data['description'] ?? 'Description',
1855 'image' => $schema_data['image']['url'] ?? $schema_data['image'] ?? null,
1856 'additional_info' => $this->extract_additional_info($schema_data, $schema_type)
1857 ];
1858 }
1859
1860 private function extract_additional_info(array $schema_data, string $schema_type): array {
1861 $info = [];
1862
1863 switch ($schema_type) {
1864 case 'Article':
1865 if (isset($schema_data['author']['name'])) {
1866 $info['author'] = $schema_data['author']['name'];
1867 }
1868 if (isset($schema_data['datePublished'])) {
1869 $timestamp = strtotime($schema_data['datePublished']);
1870 if ($timestamp !== false) {
1871 $info['date'] = gmdate('M j, Y', $timestamp);
1872 }
1873 }
1874 break;
1875 case 'Product':
1876 if (isset($schema_data['offers']['price'])) {
1877 $info['price'] = $schema_data['offers']['priceCurrency'] . $schema_data['offers']['price'];
1878 }
1879 if (isset($schema_data['brand']['name'])) {
1880 $info['brand'] = $schema_data['brand']['name'];
1881 }
1882 break;
1883 }
1884
1885 return $info;
1886 }
1887
1888 private function generate_schema_optimization_recommendations(array $generated_schemas, array $validation_results): array {
1889 $recommendations = [];
1890
1891 foreach ($validation_results as $schema_type => $validation) {
1892 if (!$validation['is_valid']) {
1893 $recommendations[] = [
1894 'type' => 'validation_error',
1895 'schema_type' => $schema_type,
1896 'priority' => 'high',
1897 'message' => "Schema validation failed for {$schema_type}",
1898 'action' => 'Fix validation errors before deployment'
1899 ];
1900 }
1901
1902 if (!empty($validation['warnings'])) {
1903 // Extract missing properties from warnings
1904 $missing_properties = [];
1905 foreach ($validation['warnings'] as $warning) {
1906 if (strpos($warning, 'Missing recommended property:') === 0) {
1907 $property = trim(str_replace('Missing recommended property:', '', $warning));
1908 $missing_properties[] = $property;
1909 }
1910 }
1911
1912 if (!empty($missing_properties)) {
1913 $properties_list = implode(', ', $missing_properties);
1914 $recommendations[] = [
1915 'type' => 'missing_properties',
1916 'schema_type' => $schema_type,
1917 'priority' => 'medium',
1918 'message' => "Missing recommended properties for {$schema_type}: {$properties_list}",
1919 'action' => 'Add these properties to improve rich snippets eligibility'
1920 ];
1921 } else {
1922 $recommendations[] = [
1923 'type' => 'missing_properties',
1924 'schema_type' => $schema_type,
1925 'priority' => 'medium',
1926 'message' => "Missing recommended properties for {$schema_type}",
1927 'action' => 'Add recommended properties to improve rich snippets eligibility'
1928 ];
1929 }
1930 }
1931 }
1932
1933 return $recommendations;
1934 }
1935
1936 private function check_deployment_readiness(array $validation_results): bool {
1937 foreach ($validation_results as $validation) {
1938 if (!$validation['is_valid']) {
1939 return false;
1940 }
1941 }
1942 return true;
1943 }
1944
1945 // Content detection helper methods
1946 private function is_local_business(): bool {
1947 // Simple check - would be enhanced with actual business detection
1948 $description = get_bloginfo('description');
1949 $local_keywords = ['restaurant', 'shop', 'store', 'clinic', 'office', 'service'];
1950
1951 foreach ($local_keywords as $keyword) {
1952 if (stripos($description, $keyword) !== false) {
1953 return true;
1954 }
1955 }
1956
1957 return false;
1958 }
1959
1960 private function is_how_to_content(string $content): bool {
1961 $how_to_keywords = ['step', 'how to', 'tutorial', 'guide', 'instructions'];
1962 $content_lower = strtolower($content);
1963
1964 foreach ($how_to_keywords as $keyword) {
1965 if (stripos($content_lower, $keyword) !== false) {
1966 return true;
1967 }
1968 }
1969
1970 return false;
1971 }
1972
1973 private function is_faq_content(string $content): bool {
1974 $faq_keywords = ['faq', 'frequently asked', 'questions', 'q:', 'a:'];
1975 $content_lower = strtolower($content);
1976
1977 foreach ($faq_keywords as $keyword) {
1978 if (stripos($content_lower, $keyword) !== false) {
1979 return true;
1980 }
1981 }
1982
1983 return false;
1984 }
1985
1986 private function determine_deployment_method(array $options): string {
1987 // Always use JSON-LD as it's the only supported method
1988 return 'json_ld';
1989 }
1990
1991 private function deploy_single_schema(array $schema, string $schema_type, string $method, string $context_type, ?int $context_id): array {
1992 global $wpdb;
1993
1994 // Use existing seo_schema table
1995 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
1996
1997 $deployment_data = [
1998 'context_type' => $context_type,
1999 'context_id' => $context_id,
2000 'schema_type' => $schema_type,
2001 'schema_data' => wp_json_encode($schema),
2002 'validation_status' => 'deployed',
2003 'is_active' => 1
2004 ];
2005
2006 // Check if schema already exists for this context and type
2007 if (null === $context_id) {
2008 // Handle NULL context_id case
2009 // 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
2010 $sql = sprintf(
2011 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id IS NULL AND schema_type = %%s',
2012 $table_name
2013 );
2014 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access
2015 $existing = $wpdb->get_var(
2016 $wpdb->prepare(
2017 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2018 $sql,
2019 $context_type,
2020 $schema_type
2021 )
2022 );
2023 } else {
2024 // Handle regular context_id case
2025 // 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
2026 $sql = sprintf(
2027 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id = %%d AND schema_type = %%s',
2028 $table_name
2029 );
2030 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access
2031 $existing = $wpdb->get_var(
2032 $wpdb->prepare(
2033 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2034 $sql,
2035 $context_type,
2036 $context_id,
2037 $schema_type
2038 )
2039 );
2040 }
2041
2042 if ($existing) {
2043 // Update existing deployment
2044 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema update requires direct database access
2045 $result = $wpdb->update(
2046 $table_name,
2047 [
2048 'schema_data' => wp_json_encode($schema),
2049 'validation_status' => 'deployed',
2050 'is_active' => 1,
2051 'updated_at' => current_time('mysql')
2052 ],
2053 ['schema_id' => $existing],
2054 ['%s', '%s', '%d', '%s'],
2055 ['%d']
2056 );
2057
2058 } else {
2059 // Insert new deployment
2060 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access
2061 $result = $wpdb->insert(
2062 $table_name,
2063 $deployment_data,
2064 ['%s', '%d', '%s', '%s', '%s', '%d']
2065 );
2066
2067 }
2068
2069 return [
2070 'deployed' => $result !== false,
2071 'method' => $method,
2072 'schema_type' => $schema_type,
2073 'schema_id' => $existing ?: $wpdb->insert_id
2074 ];
2075 }
2076
2077 /**
2078 * Get deployed schemas for frontend integration
2079 *
2080 * Returns the newest active, deployed row of each schema type for the
2081 * context. Results are cached per context (see Schema_Cache_Manager).
2082 *
2083 * @since 1.0.0
2084 *
2085 * @param string $context_type Context type
2086 * @param int|null $context_id Context ID
2087 * @return array Deployed schemas for current context
2088 */
2089 public function get_deployed_schemas(string $context_type = 'site', ?int $context_id = null): array {
2090 // CACHE LAYER: Check cache first for immediate 90% performance improvement
2091 if ($this->cache_manager) {
2092 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
2093 $cached_data = $this->cache_manager->get($cache_key);
2094
2095 if ($cached_data !== null) {
2096 // CACHE FIX: Extract actual data from cache wrapper
2097 return isset($cached_data['data']) ? $cached_data['data'] : $cached_data;
2098 }
2099 }
2100
2101 global $wpdb;
2102
2103 // Use existing seo_schema table
2104 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2105
2106 // The newest row per type used to be picked with ROW_NUMBER() OVER
2107 // (PARTITION BY schema_type ...). Window functions need MySQL 8.0 /
2108 // MariaDB 10.2, and WordPress still runs on MySQL 5.7, where that is
2109 // a syntax error on every page view and no deployed schema is ever
2110 // output. A row is the newest of its type when no other row of the
2111 // same context and type outranks it, so NOT EXISTS keeps the
2112 // greatest-per-group in the database, and schema_data — JSON, and
2113 // large — is only transferred for the rows that are output.
2114 //
2115 // `<=>` is NULL-safe equality: the site context stores context_id
2116 // as NULL, and `n.context_id = s.context_id` is never true for it.
2117 // schema_id breaks a same-second tie, which the window function
2118 // left to chance.
2119 $args = [$context_type];
2120 if (null === $context_id) {
2121 $context_where = 's.context_id IS NULL';
2122 } else {
2123 $context_where = 's.context_id = %d';
2124 $args[] = $context_id;
2125 }
2126
2127 $sql = sprintf(
2128 'SELECT s.schema_type, s.schema_data FROM %1$s s'
2129 . ' WHERE s.context_type = %%s AND %2$s AND s.is_active = 1 AND s.validation_status IN (\'deployed\', \'valid\')'
2130 . ' AND NOT EXISTS ('
2131 . 'SELECT 1 FROM %1$s n'
2132 . ' WHERE n.context_type = s.context_type AND n.context_id <=> s.context_id AND n.schema_type = s.schema_type'
2133 . ' AND n.is_active = 1 AND n.validation_status IN (\'deployed\', \'valid\')'
2134 . ' AND (n.created_at > s.created_at OR (n.created_at = s.created_at AND n.schema_id > s.schema_id))'
2135 . ')'
2136 . ' ORDER BY s.schema_type',
2137 $table_name,
2138 $context_where
2139 );
2140
2141 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2142 $deployed_schemas = $wpdb->get_results(
2143 $wpdb->prepare(
2144 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2145 $sql,
2146 ...$args
2147 ),
2148 ARRAY_A
2149 );
2150
2151 // Deliberately no early return on an empty result: it has to reach the
2152 // cache write below. Most URLs have no deployed schema, so gating the
2153 // write on a non-empty result made the majority of front-end requests
2154 // permanent cache misses, re-running the query on every pageview (#392).
2155 $deployed_schemas = $deployed_schemas ?: [];
2156
2157 // Process schemas for return
2158 $processed_schemas = [];
2159 foreach ($deployed_schemas as $deployed_schema) {
2160 $schema_data = json_decode($deployed_schema['schema_data'], true);
2161 $schema_type = $deployed_schema['schema_type'];
2162
2163 if (!empty($schema_data)) {
2164 // Remove internal validation metadata before frontend output
2165 if (isset($schema_data['_validation'])) {
2166 unset($schema_data['_validation']);
2167 }
2168
2169 // Deployed schema is a snapshot, so rows written before #465
2170 // still carry raw MySQL datetimes. Normalise on read so the
2171 // fix reaches existing sites without a migration.
2172 $schema_data = $this->normalize_stored_schema($schema_data);
2173
2174 // The permalink was frozen at deploy time, so schema deployed
2175 // while a post was a draft advertised "?p=123" as both url and
2176 // mainEntityOfPage forever — contradicting the node's own @id
2177 // and the canonical (#470). Resolve it live instead.
2178 $schema_data = $this->refresh_schema_permalink($schema_data, $context_type, $context_id);
2179
2180 // schema.org types `sameAs`, `url`, `logo` and `image` as URLs,
2181 // but the form stored whatever was typed, so free text entered
2182 // in a social-profile field shipped as a sameAs member and made
2183 // the whole entity invalid (#480). Drop bad values on read, so
2184 // existing sites stop emitting them without a migration.
2185 $schema_data = $this->filter_entity_urls($schema_data);
2186
2187 $processed_schemas[$schema_type] = [
2188 'data' => $schema_data,
2189 'method' => 'json_ld', // Default method
2190 'type' => $schema_type
2191 ];
2192 }
2193 }
2194
2195 // CACHE LAYER: Store result in cache for future requests — including
2196 // an empty one. Cache_Manager::set() wraps the payload in a metadata
2197 // envelope, so an empty result is still stored as a truthy value and
2198 // reads back as a hit rather than a miss (#392).
2199 if ($this->cache_manager) {
2200 $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id);
2201 $this->cache_manager->set($cache_key, $processed_schemas);
2202 }
2203
2204 return $processed_schemas;
2205 }
2206
2207 /**
2208 * Properties schema.org defines as URLs.
2209 *
2210 * @since 2.0.2
2211 * @var string[]
2212 */
2213 private const URL_PROPERTIES = ['sameAs', 'url', 'logo', 'image'];
2214
2215 /**
2216 * Whether a value is a URL safe to publish in structured data.
2217 *
2218 * @since 2.0.2
2219 *
2220 * @param mixed $url Candidate value.
2221 * @return bool
2222 */
2223 private function is_publishable_url($url): bool {
2224 if (!is_string($url) || '' === trim($url)) {
2225 return false;
2226 }
2227
2228 if (!filter_var($url, FILTER_VALIDATE_URL)) {
2229 return false;
2230 }
2231
2232 $scheme = wp_parse_url($url, PHP_URL_SCHEME);
2233
2234 return in_array(strtolower((string) $scheme), ['http', 'https'], true);
2235 }
2236
2237 /**
2238 * Drop values that are not URLs from URL-typed properties.
2239 *
2240 * An absent property is valid; one holding free text is not, and it can
2241 * invalidate the entity around it. Nested objects (`logo` and `image` are
2242 * frequently ImageObjects) are walked so a bad `url` inside one is caught
2243 * too. A property left with nothing is removed rather than emitted empty.
2244 *
2245 * @since 2.0.2
2246 *
2247 * @param array $schema Decoded schema data.
2248 * @return array Schema carrying only publishable URLs.
2249 */
2250 private function filter_entity_urls(array $schema): array {
2251 foreach ($schema as $key => $value) {
2252 if (is_array($value) && !in_array($key, self::URL_PROPERTIES, true)) {
2253 $schema[$key] = $this->filter_entity_urls($value);
2254 continue;
2255 }
2256
2257 if (!in_array($key, self::URL_PROPERTIES, true)) {
2258 continue;
2259 }
2260
2261 // A nested object (ImageObject and friends) carries its own url.
2262 if (is_array($value) && isset($value['@type'])) {
2263 $schema[$key] = $this->filter_entity_urls($value);
2264 continue;
2265 }
2266
2267 if (is_array($value)) {
2268 $kept = [];
2269
2270 foreach ($value as $item) {
2271 if (is_array($item)) {
2272 $kept[] = $this->filter_entity_urls($item);
2273 } elseif ($this->is_publishable_url($item)) {
2274 $kept[] = $item;
2275 }
2276 }
2277
2278 if ([] === $kept) {
2279 unset($schema[$key]);
2280 } else {
2281 $schema[$key] = array_values($kept);
2282 }
2283
2284 continue;
2285 }
2286
2287 if (!$this->is_publishable_url($value)) {
2288 unset($schema[$key]);
2289 }
2290 }
2291
2292 return $schema;
2293 }
2294
2295 /**
2296 * Schema types whose `url` identifies the entity, not the page.
2297 *
2298 * On a Person or an Organization, `url` is that entity's own website, so
2299 * overwriting it with the permalink of whichever post the schema happens to
2300 * be deployed on is simply wrong. It also breaks graph assembly: the site
2301 * identity emits the same entity with its real `url`, and once the two
2302 * copies disagree they can no longer be recognised as one entity (#479).
2303 *
2304 * @since 2.0.2
2305 * @var string[]
2306 */
2307 private const ENTITY_URL_TYPES = ['Person', 'Organization', 'LocalBusiness'];
2308
2309 /**
2310 * Replace a stored permalink snapshot with the post's live permalink.
2311 *
2312 * Only touches `url` and `mainEntityOfPage`, and only for post-like
2313 * contexts where a permalink actually exists. Identity entities are
2314 * exempt from the `url` rewrite — see self::ENTITY_URL_TYPES.
2315 *
2316 * @since 1.16.0
2317 *
2318 * @param array $schema Decoded schema data.
2319 * @param string $context_type Context type.
2320 * @param int|null $context_id Context ID.
2321 * @return array Schema with a current permalink.
2322 */
2323 private function refresh_schema_permalink(array $schema, string $context_type, ?int $context_id): array {
2324 if ('site' === $context_type || empty($context_id)) {
2325 return $schema;
2326 }
2327
2328 $permalink = get_permalink($context_id);
2329
2330 if (!$permalink) {
2331 return $schema;
2332 }
2333
2334 $type = $schema['@type'] ?? '';
2335 $type = is_array($type) ? reset($type) : $type;
2336 // A LocalBusiness is deployed under the subtype the site chose, so the
2337 // exemption has to cover every subtype, not only the literal root.
2338 $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true)
2339 || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type);
2340
2341 if (isset($schema['url']) && !$is_entity) {
2342 $schema['url'] = $permalink;
2343 }
2344
2345 if (isset($schema['mainEntityOfPage'])) {
2346 if (is_array($schema['mainEntityOfPage'])) {
2347 if (isset($schema['mainEntityOfPage']['@id'])) {
2348 $schema['mainEntityOfPage']['@id'] = $permalink;
2349 }
2350 } else {
2351 $schema['mainEntityOfPage'] = $permalink;
2352 }
2353 }
2354
2355 return $schema;
2356 }
2357
2358 /**
2359 * Normalise properties that stored snapshots may hold in a stale format.
2360 *
2361 * Deployed schema is written once and read forever, so a formatting fix in
2362 * the builder never reaches rows already on disk. Correcting on read means
2363 * existing sites benefit without a migration.
2364 *
2365 * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must
2366 * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values
2367 * inside author/publisher/@graph entries are covered too.
2368 *
2369 * Also decodes HTML entities in plain-text properties. Schema_Builder
2370 * stored the block editor's `&amp;` as-is until 2.10.0, and nothing
2371 * decodes JSON-LD downstream, so every deployed node built from post text
2372 * published the entity literally.
2373 *
2374 * @since 1.16.0
2375 * @since 2.10.0 Decodes entities in plain-text properties.
2376 *
2377 * @param array $schema Decoded schema data.
2378 * @return array Normalised schema.
2379 */
2380 private function normalize_stored_schema(array $schema): array {
2381 static $date_keys = [
2382 'datePublished', 'dateModified', 'dateCreated', 'uploadDate',
2383 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires',
2384 ];
2385
2386 // Plain text in schema.org. Answer/HowToStep `text` is deliberately
2387 // absent: Google reads Answer.text as HTML, where an entity is correct
2388 // and decoding `&lt;` would turn escaped text into live markup.
2389 static $text_keys = [
2390 'name', 'headline', 'alternativeHeadline', 'description',
2391 'reviewBody', 'about', 'abstract', 'caption',
2392 ];
2393
2394 foreach ($schema as $key => $value) {
2395 if (is_array($value)) {
2396 $schema[$key] = $this->normalize_stored_schema($value);
2397 continue;
2398 }
2399
2400 if ('inLanguage' === $key && is_string($value) && '' !== $value) {
2401 $schema[$key] = str_replace('_', '-', $value);
2402 continue;
2403 }
2404
2405 // Decode only: a snapshot already truncated with an ellipsis must
2406 // keep it, which the full Seo_Text::normalize_schema_text() would
2407 // strip as an excerpt marker.
2408 if (in_array($key, $text_keys, true) && is_string($value) && '' !== $value) {
2409 $schema[$key] = \ThinkRank\Core\Seo_Text::decode_schema_entities($value);
2410 continue;
2411 }
2412
2413 if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) {
2414 continue;
2415 }
2416
2417 // Already ISO 8601 — leave it alone.
2418 if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $value)) {
2419 continue;
2420 }
2421
2422 $timestamp = strtotime($value);
2423
2424 if (false !== $timestamp) {
2425 $schema[$key] = (string) wp_date('c', $timestamp);
2426 }
2427 }
2428
2429 return $schema;
2430 }
2431
2432 /**
2433 * Clean up duplicate schemas in database
2434 *
2435 * @since 1.0.0
2436 *
2437 * @param string $context_type Context type
2438 * @param int|null $context_id Context ID
2439 * @return int Number of duplicate schemas removed
2440 */
2441 public function cleanup_duplicate_schemas(string $context_type = 'site', ?int $context_id = null): int {
2442 global $wpdb;
2443
2444 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2445
2446 if (null === $context_id) {
2447 // Clean up duplicates for NULL context_id
2448 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
2449 $sql = sprintf(
2450 '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',
2451 $table_name,
2452 $table_name
2453 );
2454 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
2455 $deleted = $wpdb->query(
2456 $wpdb->prepare(
2457 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2458 $sql,
2459 $context_type,
2460 $context_type
2461 )
2462 );
2463 } else {
2464 // Clean up duplicates for specific context_id
2465 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
2466 $sql = sprintf(
2467 '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',
2468 $table_name,
2469 $table_name
2470 );
2471 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access
2472 $deleted = $wpdb->query(
2473 $wpdb->prepare(
2474 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2475 $sql,
2476 $context_type,
2477 $context_id,
2478 $context_type,
2479 $context_id
2480 )
2481 );
2482 }
2483
2484 return $deleted ?: 0;
2485 }
2486
2487 /**
2488 * Deactivate deployed schema rows for the given types.
2489 *
2490 * Deployment was insert-only, so anything ever deployed to a context stayed
2491 * on the page forever — switching a post's schema type left the old one live
2492 * and deactivating a saved schema did nothing (#464). Rows are deactivated
2493 * rather than deleted so a later redeploy can revive them and so there is a
2494 * trail of what was published.
2495 *
2496 * @since 1.16.0
2497 *
2498 * @param string $context_type Context type.
2499 * @param int|null $context_id Context ID.
2500 * @param string[] $schema_types Types to retire.
2501 * @return int Number of rows deactivated.
2502 */
2503 private function retire_schema_types(string $context_type, ?int $context_id, array $schema_types): int {
2504 $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
2505
2506 if (empty($schema_types)) {
2507 return 0;
2508 }
2509
2510 global $wpdb;
2511
2512 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2513 $placeholders = implode(', ', array_fill(0, count($schema_types), '%s'));
2514
2515 if (null === $context_id) {
2516 $sql = sprintf(
2517 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id IS NULL AND schema_type IN (%s)',
2518 $table_name,
2519 $placeholders
2520 );
2521 $args = array_merge([$context_type], $schema_types);
2522 } else {
2523 $sql = sprintf(
2524 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id = %%d AND schema_type IN (%s)',
2525 $table_name,
2526 $placeholders
2527 );
2528 $args = array_merge([$context_type, $context_id], $schema_types);
2529 }
2530
2531 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Retiring deployed schema rows requires direct database access.
2532 $updated = $wpdb->query(
2533 $wpdb->prepare(
2534 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is built from an internal table name and generated placeholders.
2535 $sql,
2536 $args
2537 )
2538 );
2539
2540 if ($updated && $this->cache_manager) {
2541 $this->cache_manager->invalidate_context_cache($context_type, $context_id);
2542 }
2543
2544 return (int) ($updated ?: 0);
2545 }
2546
2547 /**
2548 * Retire deployed types that are no longer in the user's Schema Types selection.
2549 *
2550 * An empty selection means "auto-detect", so nothing is retired in that case.
2551 *
2552 * @since 1.16.0
2553 *
2554 * @param string $context_type Context type.
2555 * @param int|null $context_id Context ID.
2556 * @param array $enabled_types The user's selected types.
2557 * @return int Number of rows deactivated.
2558 */
2559 private function retire_unselected_schema_types(string $context_type, ?int $context_id, array $enabled_types): int {
2560 if (empty($enabled_types)) {
2561 return 0;
2562 }
2563
2564 $deployed = array_keys($this->get_deployed_schemas($context_type, $context_id));
2565 $stale = array_diff($deployed, $enabled_types);
2566
2567 return $this->retire_schema_types($context_type, $context_id, $stale);
2568 }
2569
2570 /**
2571 * Delete stored schemas for a context before storing new ones.
2572 *
2573 * `$schema_types` scopes the delete to the types actually being rewritten.
2574 * Without it this wiped every type in the context, which silently destroyed
2575 * deployed schema whenever a caller regenerated a subset — and
2576 * auto_deploy_schema_on_settings_change() regenerates one type at a time
2577 * (#454). Passing an empty array keeps the original clear-the-context
2578 * behaviour for callers that genuinely rewrite everything.
2579 *
2580 * @since 1.0.0
2581 *
2582 * @param string $context_type Context type
2583 * @param int|null $context_id Context ID
2584 * @param string[] $schema_types Optional. Limit the delete to these types.
2585 * @return int Number of schemas deleted
2586 */
2587 private function delete_existing_schemas(string $context_type, ?int $context_id, array $schema_types = []): int {
2588 global $wpdb;
2589
2590 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2591
2592 // Build an optional `AND schema_type IN (…)` clause with one prepared
2593 // placeholder per type, so the scoping cannot be injected through.
2594 $type_clause = '';
2595 $type_values = [];
2596 $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen'));
2597 if (!empty($schema_types)) {
2598 $type_clause = ' AND schema_type IN (' . implode(', ', array_fill(0, count($schema_types), '%s')) . ')';
2599 $type_values = $schema_types;
2600 }
2601
2602 if (null === $context_id) {
2603 // Delete all schemas for NULL context_id
2604 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
2605 $sql = sprintf(
2606 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL%s',
2607 $table_name,
2608 $type_clause
2609 );
2610 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
2611 $deleted = $wpdb->query(
2612 $wpdb->prepare(
2613 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2614 $sql,
2615 array_merge([$context_type], $type_values)
2616 )
2617 );
2618 } else {
2619 // Delete all schemas for specific context_id
2620 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
2621 $sql = sprintf(
2622 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d%s',
2623 $table_name,
2624 $type_clause
2625 );
2626 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access
2627 $deleted = $wpdb->query(
2628 $wpdb->prepare(
2629 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2630 $sql,
2631 array_merge([$context_type, $context_id], $type_values)
2632 )
2633 );
2634 }
2635
2636 return $deleted ?: 0;
2637 }
2638
2639 /**
2640 * Get business data from Site Identity Local settings
2641 *
2642 * @return array
2643 */
2644 private function get_business_data_from_local_seo(): array {
2645 // Get Site Identity settings which include Local SEO data
2646 $site_identity_settings = get_option('thinkrank_site_identity_settings', []);
2647
2648 return [
2649 'business_name' => $site_identity_settings['business_name'] ?? '',
2650 'business_address' => $site_identity_settings['business_address'] ?? '',
2651 'business_city' => $site_identity_settings['business_city'] ?? '',
2652 'business_state' => $site_identity_settings['business_state'] ?? '',
2653 'business_postal_code' => $site_identity_settings['business_postal_code'] ?? '',
2654 'business_country' => $site_identity_settings['business_country'] ?? '',
2655 'business_phone' => $site_identity_settings['business_phone'] ?? '',
2656 'business_email' => $site_identity_settings['business_email'] ?? '',
2657 'business_hours' => $site_identity_settings['business_hours'] ?? [],
2658 'business_type' => $site_identity_settings['business_type'] ?? 'LocalBusiness'
2659 ];
2660 }
2661 public function get_settings(string $context_type, ?int $context_id = null): array {
2662 // Get base settings from parent
2663 $settings = parent::get_settings($context_type, $context_id);
2664
2665 // For site context, automatically include Site Identity data
2666 if ($context_type === 'site') {
2667 // Get Site Identity settings from the Site Identity Manager
2668 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
2669 $site_identity_settings = $site_identity_manager->get_settings('site', null);
2670
2671 // Include Site Identity assets if not already set in Schema Manager
2672 if (empty($settings['logo_url']) && !empty($site_identity_settings['logo_url'])) {
2673 $settings['logo_url'] = $site_identity_settings['logo_url'];
2674 }
2675 if (empty($settings['favicon_url']) && !empty($site_identity_settings['favicon_url'])) {
2676 $settings['favicon_url'] = $site_identity_settings['favicon_url'];
2677 }
2678 if (empty($settings['apple_touch_icon_url']) && !empty($site_identity_settings['apple_touch_icon_url'])) {
2679 $settings['apple_touch_icon_url'] = $site_identity_settings['apple_touch_icon_url'];
2680 }
2681
2682 // Include Site Identity organization data if not already set in Schema Manager
2683 if (empty($settings['organization_name']) && !empty($site_identity_settings['site_name'])) {
2684 $settings['organization_name'] = $site_identity_settings['site_name'];
2685 }
2686 if (empty($settings['organization_url']) && !empty($site_identity_settings['site_url'])) {
2687 $settings['organization_url'] = $site_identity_settings['site_url'];
2688 }
2689 if (empty($settings['organization_description']) && !empty($site_identity_settings['site_description'])) {
2690 $settings['organization_description'] = $site_identity_settings['site_description'];
2691 }
2692 }
2693
2694 return $settings;
2695 }
2696
2697 /**
2698 * Get site data for rich schema generation
2699 *
2700 * @return array
2701 */
2702 private function get_site_data_for_schema(): array {
2703 // Get Schema Manager's own settings first (highest priority)
2704 $schema_settings = $this->get_settings('site', null);
2705
2706 // Get Site Identity settings for additional data
2707 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
2708 $site_identity_settings = $site_identity_manager->get_settings('site', null);
2709
2710 return [
2711 'site_name' => get_bloginfo('name'),
2712 'site_description' => get_bloginfo('description'),
2713 'site_url' => home_url(),
2714 'admin_email' => get_option('admin_email'),
2715 'language' => get_locale(),
2716 'timezone' => get_option('timezone_string'),
2717 // Read by populate_website_schema(), so the deployed WebSite node
2718 // carries the same alternateName as the default one (#692).
2719 'alternate_name' => $site_identity_settings['alternate_name'] ?? '',
2720 'founded_date' => $site_identity_settings['founded_date'] ?? '',
2721 'founder_name' => $site_identity_settings['founder_name'] ?? '',
2722 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
2723 // Site Identity assets
2724 'logo_url' => $site_identity_settings['logo_url'] ?? '',
2725 'favicon_url' => $site_identity_settings['favicon_url'] ?? '',
2726 // Schema Manager organization settings (highest priority)
2727 'organization_name' => $schema_settings['organization_name'] ?? '',
2728 'organization_description' => $schema_settings['organization_description'] ?? '',
2729 'organization_url' => $schema_settings['organization_url'] ?? '',
2730
2731 // Removed post/page-specific schema settings (Product, Event, Article, Software Application)
2732 // These are now handled only at the post/page level via metabox
2733
2734 // Person schema settings (site-wide)
2735 'person_name' => $schema_settings['person_name'] ?? '',
2736 'person_job_title' => $schema_settings['person_job_title'] ?? '',
2737 'person_description' => $schema_settings['person_description'] ?? '',
2738 'person_image' => $schema_settings['person_image'] ?? '',
2739 'person_url' => $schema_settings['person_url'] ?? '',
2740 'person_email' => $schema_settings['person_email'] ?? '',
2741 'person_telephone' => $schema_settings['person_telephone'] ?? '',
2742 'person_address' => $schema_settings['person_address'] ?? '',
2743 'person_birth_date' => $schema_settings['person_birth_date'] ?? '',
2744 'person_nationality' => $schema_settings['person_nationality'] ?? '',
2745 'person_works_for' => $schema_settings['person_works_for'] ?? '',
2746 'person_same_as' => $schema_settings['person_same_as'] ?? [],
2747
2748 // Website schema settings (site-wide)
2749 'website_name' => $schema_settings['website_name'] ?? '',
2750 'website_url' => $schema_settings['website_url'] ?? '',
2751 'website_description' => $schema_settings['website_description'] ?? '',
2752 'website_author' => $schema_settings['website_author'] ?? '',
2753
2754 'organization_logo' => $schema_settings['organization_logo'] ?? '',
2755 // Social media links (sameAs)
2756 'organization_social_facebook' => $schema_settings['organization_social_facebook'] ?? '',
2757 'organization_social_twitter' => $schema_settings['organization_social_twitter'] ?? '',
2758 'organization_social_linkedin' => $schema_settings['organization_social_linkedin'] ?? '',
2759 'organization_social_instagram' => $schema_settings['organization_social_instagram'] ?? '',
2760 'organization_social_youtube' => $schema_settings['organization_social_youtube'] ?? '',
2761 'organization_social_pinterest' => $schema_settings['organization_social_pinterest'] ?? '',
2762 'organization_social_whatsapp' => $schema_settings['organization_social_whatsapp'] ?? '',
2763 'organization_social_telegram' => $schema_settings['organization_social_telegram'] ?? '',
2764 // Contact point information
2765 'organization_contact_type' => $schema_settings['organization_contact_type'] ?? 'customer service',
2766 'organization_contact_phone' => $schema_settings['organization_contact_phone'] ?? '',
2767 'organization_contact_email' => $schema_settings['organization_contact_email'] ?? '',
2768 'organization_contact_hours' => $schema_settings['organization_contact_hours'] ?? '',
2769 // LocalBusiness specific fields
2770 'business_price_range' => $schema_settings['business_price_range'] ?? '',
2771 'business_geo_latitude' => $schema_settings['business_geo_latitude'] ?? '',
2772 'business_geo_longitude' => $schema_settings['business_geo_longitude'] ?? '',
2773 'business_opening_hours' => $schema_settings['business_opening_hours'] ?? []
2774 ];
2775 }
2776
2777 /**
2778 * Get social media data for schema generation
2779 *
2780 * @return array
2781 */
2782 private function get_social_data_for_schema(): array {
2783 // Get Social Media settings
2784 $social_settings = get_option('thinkrank_social_media_settings', []);
2785
2786 $social_profiles = [];
2787
2788 // Common social platforms
2789 $platforms = ['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'tiktok', 'pinterest'];
2790
2791 foreach ($platforms as $platform) {
2792 $url = $social_settings["{$platform}_url"] ?? '';
2793 if (!empty($url)) {
2794 $social_profiles[] = $url;
2795 }
2796 }
2797
2798 return [
2799 'social_profiles' => $social_profiles,
2800 'social_settings' => $social_settings
2801 ];
2802 }
2803 }
2804