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

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