PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 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.14.3, at includes/seo/class-schema-management-system.php

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