[ 'name' => 'Article', 'description' => 'News articles, blog posts, and editorial content', 'required_properties' => ['headline', 'author', 'datePublished'], 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage'], 'rich_snippets' => ['article', 'news_article', 'blog_posting'], 'context_types' => ['post', 'page'], 'priority' => 'high' ], 'TechnicalArticle' => [ 'name' => 'TechnicalArticle', 'description' => 'Technical documentation and tutorials', 'required_properties' => ['headline', 'author', 'datePublished'], 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'dependencies', 'proficiencyLevel'], 'rich_snippets' => ['article', 'technical_article'], 'context_types' => ['post', 'page'], 'priority' => 'high' ], 'NewsArticle' => [ 'name' => 'NewsArticle', 'description' => 'News articles and press releases', 'required_properties' => ['headline', 'author', 'datePublished'], 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'dateline'], 'rich_snippets' => ['article', 'news_article'], 'context_types' => ['post', 'page'], 'priority' => 'high' ], 'ScholarlyArticle' => [ 'name' => 'ScholarlyArticle', 'description' => 'Academic and research articles', 'required_properties' => ['headline', 'author', 'datePublished'], 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'citation', 'abstract'], 'rich_snippets' => ['article', 'scholarly_article'], 'context_types' => ['post', 'page'], 'priority' => 'high' ], 'Report' => [ 'name' => 'Report', 'description' => 'Reports and analytical content', 'required_properties' => ['headline', 'author', 'datePublished'], 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage'], 'rich_snippets' => ['article', 'report'], 'context_types' => ['post', 'page'], 'priority' => 'medium' ], 'Product' => [ 'name' => 'Product', 'description' => 'Products for e-commerce and retail', 'required_properties' => ['name', 'description'], 'recommended_properties' => ['image', 'offers', 'brand', 'sku', 'gtin', 'review', 'aggregateRating'], 'rich_snippets' => ['product', 'offer', 'review'], 'context_types' => ['product', 'page', 'post'], 'priority' => 'critical' ], 'LocalBusiness' => [ 'name' => 'LocalBusiness', 'description' => 'Local businesses and service providers', 'required_properties' => ['name', 'address'], 'recommended_properties' => ['telephone', 'url', 'openingHours', 'geo', 'priceRange'], 'rich_snippets' => ['local_business', 'organization'], 'context_types' => ['site', 'page'], 'priority' => 'high' ], 'Organization' => [ 'name' => 'Organization', 'description' => 'Companies, corporations, and institutions', 'required_properties' => ['name', 'url'], 'recommended_properties' => ['logo', 'contactPoint', 'sameAs', 'address'], 'rich_snippets' => ['organization', 'corporation'], 'context_types' => ['site'], 'priority' => 'medium' ], 'WebSite' => [ 'name' => 'WebSite', 'description' => 'Website and web application information', 'required_properties' => ['name', 'url'], 'recommended_properties' => ['description', 'author', 'publisher', 'potentialAction'], 'rich_snippets' => ['website', 'sitelinks_searchbox'], 'context_types' => ['site'], 'priority' => 'high' ], 'SoftwareApplication' => [ 'name' => 'SoftwareApplication', 'description' => 'Software applications and web apps', 'required_properties' => ['name', 'applicationCategory'], 'recommended_properties' => ['description', 'url', 'offers', 'creator', 'features', 'aggregateRating'], 'rich_snippets' => ['software', 'app_rating', 'pricing'], 'context_types' => ['post', 'page'], 'priority' => 'high' ], 'Person' => [ 'name' => 'Person', 'description' => 'Individual people and authors', 'required_properties' => ['name'], 'recommended_properties' => ['url', 'image'], 'optional_properties' => ['jobTitle', 'worksFor', 'sameAs', 'description'], 'rich_snippets' => ['person', 'author'], 'context_types' => ['post', 'page'], 'priority' => 'medium' ], 'HowTo' => [ 'name' => 'HowTo', 'description' => 'Step-by-step instructions and tutorials', 'required_properties' => ['name', 'step'], 'recommended_properties' => ['image', 'totalTime', 'estimatedCost', 'tool', 'supply'], 'rich_snippets' => ['how_to', 'recipe'], 'context_types' => ['post', 'page'], 'priority' => 'medium' ], 'Event' => [ 'name' => 'Event', 'description' => 'Events, conferences, and gatherings', 'required_properties' => ['name', 'startDate', 'location'], 'recommended_properties' => ['endDate', 'description', 'image', 'offers', 'performer'], 'rich_snippets' => ['event', 'social_event'], 'context_types' => ['post', 'page'], 'priority' => 'medium' ], 'VideoObject' => [ 'name' => 'VideoObject', 'description' => 'Videos and embedded media content', 'required_properties' => ['name', 'description', 'thumbnailUrl', 'uploadDate'], 'recommended_properties' => ['contentUrl', 'embedUrl', 'duration'], 'rich_snippets' => ['video', 'video_carousel'], 'context_types' => ['post', 'page'], 'priority' => 'medium' ], // Offered by the metabox dropdown and registered in Schema_Factory, but // absent here — generate_schema_markup() keys off this array, so a // Review request was silently skipped (#462). 'Review' => [ 'name' => 'Review', 'description' => 'Reviews and ratings of a product, service or place', 'required_properties' => ['itemReviewed', 'reviewRating', 'author'], 'recommended_properties' => ['reviewBody', 'datePublished', 'publisher'], 'rich_snippets' => ['review', 'review_snippet'], 'context_types' => ['post', 'page'], 'priority' => 'medium' ], 'Recipe' => [ 'name' => 'Recipe', 'description' => 'Cooking recipes and food preparation', 'required_properties' => ['name', 'image', 'author', 'datePublished', 'description', 'recipeIngredient', 'recipeInstructions'], 'recommended_properties' => ['cookTime', 'prepTime', 'totalTime', 'recipeYield', 'nutrition'], 'rich_snippets' => ['recipe', 'cooking'], 'context_types' => ['post', 'page'], 'priority' => 'medium' ], 'BlogPosting' => [ 'name' => 'BlogPosting', 'description' => 'Blog posts and personal articles', 'required_properties' => ['headline', 'author', 'datePublished'], 'recommended_properties' => ['image', 'publisher', 'dateModified', 'mainEntityOfPage', 'wordCount'], 'rich_snippets' => ['article', 'blog_posting'], 'context_types' => ['post'], 'priority' => 'high' ], 'WebPage' => [ 'name' => 'WebPage', 'description' => 'Individual web pages', 'required_properties' => ['name', 'url'], 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'], 'rich_snippets' => ['webpage', 'breadcrumb'], 'context_types' => ['page'], 'priority' => 'medium' ], 'FAQPage' => [ 'name' => 'FAQPage', 'description' => 'Frequently Asked Questions pages', 'required_properties' => ['mainEntity'], 'recommended_properties' => ['about', 'author'], 'rich_snippets' => ['faq', 'question'], 'context_types' => ['page', 'post'], 'priority' => 'high' ], ]; /** * Rich snippets configuration with Google guidelines * * @since 1.0.0 * @var array */ private array $rich_snippets_config = [ 'testing_tools' => [ 'google_structured_data' => 'https://search.google.com/test/rich-results', 'schema_markup_validator' => 'https://validator.schema.org/', 'google_rich_results' => 'https://search.google.com/search-console/rich-results' ], 'appearance_tracking' => [ 'search_appearance' => ['title', 'description', 'image', 'rating', 'price'], 'rich_features' => ['breadcrumbs', 'sitelinks', 'reviews', 'faq', 'how_to'], 'performance_metrics' => ['click_through_rate', 'impressions', 'position'] ], 'optimization_guidelines' => [ 'image_requirements' => [ 'min_width' => 1200, 'min_height' => 675, 'aspect_ratio' => '16:9', 'formats' => ['jpg', 'png', 'webp'] ], 'content_requirements' => [ 'min_description_length' => 50, 'max_description_length' => 300, 'required_fields_completion' => 80 ] ] ]; /** * Schema validation rules and requirements * * @since 1.0.0 * @var array */ private array $validation_rules = [ 'required_context' => '@context', 'required_type' => '@type', 'url_validation' => [ 'protocols' => ['http', 'https'], 'format_check' => true ], 'date_validation' => [ 'format' => 'ISO8601', 'timezone_aware' => true ], 'image_validation' => [ 'url_required' => true, 'dimensions_check' => true, 'format_validation' => true ], 'text_validation' => [ 'html_allowed' => false, 'length_limits' => true, 'encoding' => 'UTF-8' ] ]; /** * Schema deployment configuration * * @since 1.0.0 * @var array */ private array $deployment_config = [ 'output_methods' => [ 'json_ld' => [ 'enabled' => true, 'priority' => 1, 'location' => 'head' ] ], 'caching' => [ 'enabled' => true, 'duration' => 3600, // 1 hour 'invalidation_triggers' => ['content_update', 'settings_change'] ], 'conditional_loading' => [ 'context_specific' => true, 'user_agent_detection' => false, 'performance_based' => true ] ]; /** * Schema Builder instance for schema construction * * @var \ThinkRank\SEO\Schema_Builder|null */ private ?\ThinkRank\SEO\Schema_Builder $schema_builder = null; /** * Schema Cache Manager instance for performance optimization * * @since 1.0.0 * @var Schema_Cache_Manager|null */ private ?Schema_Cache_Manager $cache_manager = null; /** * Whether the foreign-settings listener has been registered this request. * * Static because `thinkrank_seo_settings_saved` is a global hook — one * listener serves every instance. See the constructor for why (#463). * * @since 1.16.0 * @var bool */ private static bool $foreign_settings_listener_registered = false; /** * Constructor * * @since 1.0.0 */ public function __construct() { parent::__construct('schema_management_system'); // Load shared settings configuration if (!class_exists('ThinkRank\\Config\\Schema_Settings_Config')) { require_once THINKRANK_PLUGIN_DIR . 'includes/config/schema-settings-config.php'; } // Initialize Schema Builder for schema construction $this->initialize_schema_builder(); // Initialize Schema Cache Manager for performance optimization $this->initialize_cache_manager(); // LocalBusiness and Organization both read Business Info, which Site // Identity owns. Without this, editing an address or phone number never // refreshed the deployed schema (#455). // // Registered at most once per request. WordPress keys callbacks by // object hash, so binding $this here added a fresh listener for every // instance — and this class is constructed from inside the very callback // it registers, which doubled the listener count on every settings save // (#463). The guard is static because the hook itself is global. if (!self::$foreign_settings_listener_registered) { self::$foreign_settings_listener_registered = true; add_action('thinkrank_seo_settings_saved', [$this, 'refresh_schema_for_foreign_settings'], 10, 4); } } /** * Regenerate schema when another manager saves settings this schema reads. * * Site Identity owns the Business Info fields that feed LocalBusiness and * the Organization address/contactPoint, so a save there has to refresh the * deployed schema even though no schema setting changed. * * @since 2.0.2 * * @param string $manager_type Settings category that was saved. * @param array $settings Settings that were written. * @param string $context_type Context type. * @param int|null $context_id Context ID. * @return void */ public function refresh_schema_for_foreign_settings( string $manager_type, array $settings, string $context_type, ?int $context_id ): void { if ('site_identity' !== $manager_type) { return; } $business_keys = [ 'business_name', 'business_type', 'business_address', 'business_city', 'business_state', 'business_postal_code', 'business_country', 'business_phone', 'business_email', 'business_hours', 'business_latitude', 'business_longitude', 'business_price_range', ]; if (empty(array_intersect_key($settings, array_flip($business_keys)))) { return; } $schema_settings = $this->get_settings($context_type, $context_id); if (empty($schema_settings['auto_deploy'])) { return; } // Only refresh types that are actually deployed, so this never adds a // type the admin did not enable. $deployed = array_keys((array) $this->get_deployed_schemas($context_type, $context_id)); $affected = array_values(array_intersect($deployed, ['LocalBusiness', 'Organization'])); if (empty($affected)) { return; } try { $generation = $this->generate_schema_markup($context_type, $context_id, $affected); // Deploy the types that validated, not all-or-nothing. Gating on // deployment_ready meant one invalid type blocked every valid one // in the same batch (#470). $deployable = []; foreach ($affected as $type) { if (!empty($generation['generated_schemas'][$type]) && !empty($generation['validation_results'][$type]['is_valid']) ) { $deployable[$type] = $generation['generated_schemas'][$type]; } } if (!empty($deployable)) { $this->deploy_schema_markup($context_type, $context_id, $deployable); } } catch (\Exception $e) { if (defined('WP_DEBUG') && WP_DEBUG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log('ThinkRank: Business Info schema refresh failed: ' . $e->getMessage()); } } } /** * Initialize Schema Builder * * @return void */ private function initialize_schema_builder(): void { if (!class_exists('ThinkRank\\SEO\\Schema_Builder')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-builder.php'; } if (class_exists('ThinkRank\\SEO\\Schema_Builder')) { $this->schema_builder = new \ThinkRank\SEO\Schema_Builder(); } } /** * Initialize Schema Cache Manager * * @since 1.0.0 * * @return void */ private function initialize_cache_manager(): void { if (!class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-cache-manager.php'; } if (class_exists('ThinkRank\\SEO\\Schema_Cache_Manager')) { // Honour the stored cache_duration setting. It is exposed in // get_settings_schema() (min 300 / max 86400), validated, persisted // and surfaced through both abilities — but the cache manager was // always built from the hardcoded config value, so the setting had // no effect (#473). Falls back to the config default. $cache_duration = $this->deployment_config['caching']['duration'] ?? 3600; $stored = $this->get_settings('site', null)['cache_duration'] ?? null; if (is_numeric($stored) && (int) $stored > 0) { $cache_duration = (int) $stored; } $this->cache_manager = new Schema_Cache_Manager($cache_duration); } } /** * Generate schema markup with comprehensive content analysis integration * * @since 1.0.0 * * Generation is read-only by default. Persisting the result is opt-in via * `$options['persist']`, because this method is also reached from the * front-end read path (get_output_data()) and from GET routes — where a * DELETE + INSERT would destroy the admin's deployed rows and publish * types nobody deployed (#460). * * @param string $context_type Context type * @param int|null $context_id Context ID * @param array $schema_types Schema types to generate * @param array $options Generation options. Pass `persist => true` * from explicit write paths only. * @return array Comprehensive schema generation results */ public function generate_schema_markup(string $context_type, ?int $context_id, array $schema_types = [], array $options = []): array { $generation = [ 'context_type' => $context_type, 'context_id' => $context_id, 'generated_schemas' => [], 'validation_results' => [], 'rich_snippets_preview' => [], 'optimization_recommendations' => [], 'deployment_ready' => false, 'generation_timestamp' => current_time('mysql') ]; // Auto-detect schema types if not provided if (empty($schema_types)) { $schema_types = $this->auto_detect_schema_types($context_type, $context_id); } // Apply content schema settings from options $schema_types = $this->apply_content_schema_settings_from_options($schema_types, $options, $context_type); // Simplified: Content analysis and optimization moved to separate services // Schema generation focuses on core structured data creation // Generate schema for each type using Schema_Builder directly foreach ($schema_types as $schema_type) { if (isset($this->schema_types[$schema_type])) { // Prepare content data for Schema_Builder $content_data = $this->prepare_content_data_for_generator( $context_type, $context_id, [], // Simplified: content analysis handled by separate services [], // Simplified: optimization data handled by separate services $options ); // Simplified: Knowledge graph enhancements moved to separate service // Generate schema using Schema_Builder directly $schema_data = $this->schema_builder->build_schema( $schema_type, $content_data, $context_type ); // Apply rich snippets optimization if enabled if ($options['rich_snippets_optimization'] ?? true) { $schema_data = $this->apply_rich_snippets_optimization($schema_data, $schema_type); } $generation['generated_schemas'][$schema_type] = $schema_data; // Validate generated schema using proper Schema_Validator $validation = $this->validate_schema_markup($schema_data, $schema_type); $generation['validation_results'][$schema_type] = $validation; // Generate rich snippets preview $preview = $this->generate_rich_snippets_preview($schema_data, $schema_type); $generation['rich_snippets_preview'][$schema_type] = $preview; } } // Generate optimization recommendations $generation['optimization_recommendations'] = $this->generate_schema_optimization_recommendations( $generation['generated_schemas'], $generation['validation_results'] ); // Check deployment readiness $generation['deployment_ready'] = $this->check_deployment_readiness($generation['validation_results']); // Persistence belongs to deployment, not generation. Every write path // (refresh_schema_for_foreign_settings(), auto_deploy_schema_on_settings_change(), // the deploy route) calls deploy_schema_markup() straight after generating, // so nothing needs to opt in today — the flag exists to keep this an // explicit decision rather than an accident. if (!empty($options['persist'])) { $this->store_schema_data($context_type, $context_id, $generation); } return $generation; } /** * Validate schema markup with comprehensive testing * * @since 1.0.0 * * @param array $schema_data Schema data to validate * @param string $schema_type Schema type * @param array $options Validation options * @return array Comprehensive validation results */ public function validate_schema_markup(array $schema_data, string $schema_type, array $options = []): array { $validation = [ 'schema_type' => $schema_type, 'is_valid' => false, 'validation_score' => 0, 'required_properties_check' => [], 'recommended_properties_check' => [], 'structural_validation' => [], 'google_guidelines_compliance' => [], 'rich_snippets_eligibility' => [], 'errors' => [], 'warnings' => [], 'suggestions' => [], 'validation_timestamp' => current_time('mysql') ]; // Use Schema_Validator for validation if (!class_exists('ThinkRank\\SEO\\Schema_Validator')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-validator.php'; } $schema_validator = new \ThinkRank\SEO\Schema_Validator(); $validator_validation = $schema_validator->validate_schema($schema_data); // Map Schema_Validator validation to our format if ($validator_validation) { $validation['is_valid'] = $validator_validation['valid'] ?? false; $validation['validation_score'] = $validator_validation['score'] ?? 0; $validation['errors'] = $validator_validation['errors'] ?? []; $validation['warnings'] = $validator_validation['warnings'] ?? []; $validation['suggestions'] = $validator_validation['suggestions'] ?? []; // Set basic validation checks $validation['required_properties_check'] = ['status' => 'checked']; $validation['recommended_properties_check'] = ['status' => 'checked']; $validation['structural_validation'] = ['valid_structure' => $validation['is_valid']]; $validation['google_guidelines_compliance'] = ['compliant' => $validation['is_valid']]; $validation['rich_snippets_eligibility'] = ['eligible' => $validation['is_valid']]; } else { // Fallback validation $validation['is_valid'] = !empty($schema_data['@type']); $validation['validation_score'] = $validation['is_valid'] ? 85 : 0; } return $validation; } /** * Optimize rich snippets with preview and testing capabilities * * @since 1.0.0 * * @param array $schema_data Schema data to optimize * @param string $schema_type Schema type * @param array $options Optimization options * @return array Rich snippets optimization results */ public function optimize_rich_snippets(array $schema_data, string $schema_type, array $options = []): array { $optimization = [ 'schema_type' => $schema_type, 'original_schema' => $schema_data, 'optimized_schema' => [], 'rich_snippets_preview' => [], 'optimization_score' => 0, 'appearance_probability' => 0, 'optimization_changes' => [], 'testing_results' => [], 'recommendations' => [], 'optimization_timestamp' => current_time('mysql') ]; // Rich snippets optimization is not yet fully implemented // Return the original schema with basic optimization info $optimization['optimized_schema'] = $schema_data; // Generate rich snippets preview $optimization['rich_snippets_preview'] = $this->generate_rich_snippets_preview($schema_data, $schema_type); // Basic optimization metrics $optimization['optimization_score'] = 85; // Default score $optimization['appearance_probability'] = 75; // Default probability $optimization['optimization_changes'] = []; $optimization['testing_results'] = ['status' => 'not_implemented']; $optimization['recommendations'] = ['message' => 'Rich snippets optimization is not yet fully implemented']; return $optimization; } /** * Deploy schema markup with automated implementation * * @since 1.0.0 * * @param string $context_type Context type * @param int|null $context_id Context ID * @param array $schema_data Schema data to deploy * @param array $options Deployment options * @return array Schema deployment results */ public function deploy_schema_markup(string $context_type, ?int $context_id, array $schema_data, array $options = []): array { $deployment = [ 'context_type' => $context_type, 'context_id' => $context_id, 'deployment_method' => 'json_ld', // Always JSON-LD (only supported method) 'deployment_status' => 'pending', 'deployed_schemas' => [], 'deployment_location' => 'head', 'cache_status' => [], 'validation_post_deployment' => [], 'deployment_timestamp' => current_time('mysql') ]; // Determine deployment method $deployment['deployment_method'] = $this->determine_deployment_method($options); // When the caller owns the whole context — the user pressing Deploy, where // the payload is exactly what the preview showed — anything not in that // payload should come off the page (#464). Incremental callers such as // auto_deploy_schema_on_settings_change() pass only the types they // regenerated, so they must NOT retire the rest. if (!empty($options['authoritative'])) { $deployment['retired_schemas'] = $this->retire_schema_types( $context_type, $context_id, array_diff( array_keys($this->get_deployed_schemas($context_type, $context_id)), array_keys($schema_data) ) ); } // Deploy each schema foreach ($schema_data as $schema_type => $schema) { $deploy_result = $this->deploy_single_schema($schema, $schema_type, $deployment['deployment_method'], $context_type, $context_id); $deployment['deployed_schemas'][$schema_type] = $deploy_result; } // Clean up duplicate schemas $this->cleanup_duplicate_schemas($context_type, $context_id); // CACHE INVALIDATION: Clear cache after successful deployment $cache_invalidated = false; if ($this->cache_manager && !empty($deployment['deployed_schemas'])) { $this->cache_manager->invalidate_context_cache($context_type, $context_id); $cache_invalidated = true; } $deployment['cache_status'] = $cache_invalidated ? ['cache_updated' => true, 'message' => 'Schema cache invalidated'] : ['cache_updated' => false, 'message' => 'No schema cache to invalidate']; // Post-deployment verification: read back through the same accessor the // front end uses, so a row that was written but is not retrievable (wrong // context, inactive, stale cache) is reported as a failure instead of // being assumed successful. $deployment['validation_post_deployment'] = $this->verify_deployment( $context_type, $context_id, array_keys($deployment['deployed_schemas']) ); $writes_ok = !empty($deployment['deployed_schemas']); foreach ($deployment['deployed_schemas'] as $deploy_result) { if (empty($deploy_result['deployed'])) { $writes_ok = false; break; } } $deployment['deployment_status'] = ($writes_ok && !empty($deployment['validation_post_deployment']['validation_passed'])) ? 'success' : 'failed'; return $deployment; } /** * Verify deployed schema is retrievable after a deploy. * * Reads back through get_deployed_schemas() — the same accessor * Frontend\SEO_Manager::output_site_schema_markup() uses to emit schema — so * the check reflects what will actually reach the page rather than only that * an INSERT returned without error. * * @since 1.32.0 * * @param string $context_type Context type * @param int|null $context_id Context ID * @param array $expected_types Schema types that were just deployed * @return array Validation result */ private function verify_deployment(string $context_type, ?int $context_id, array $expected_types): array { if (empty($expected_types)) { return [ 'validation_passed' => false, 'message' => 'No schema was deployed', 'missing_types' => [] ]; } $retrieved = $this->get_deployed_schemas($context_type, $context_id); $missing = array_values(array_diff($expected_types, array_keys($retrieved))); if (!empty($missing)) { return [ 'validation_passed' => false, 'message' => sprintf( /* translators: %s: comma-separated list of schema types */ __('Deployed schema could not be read back: %s', 'thinkrank'), implode(', ', $missing) ), 'missing_types' => $missing ]; } return [ 'validation_passed' => true, 'message' => __('Schema deployed and read back from storage', 'thinkrank'), 'missing_types' => [] ]; } /** * Track schema performance and rich snippet appearances * * @since 1.0.0 * * @param string $context_type Context type * @param int|null $context_id Context ID * @param array $options Tracking options * @return array Schema performance tracking results */ public function track_schema_performance(string $context_type, ?int $context_id, array $options = []): array { // Performance tracking is not yet implemented // This method returns empty data structure for API compatibility return [ 'context_type' => $context_type, 'context_id' => $context_id, 'rich_snippets_appearances' => [], 'search_performance' => [], 'click_through_rates' => [], 'schema_errors' => [], 'performance_trends' => [], 'optimization_impact' => [], 'tracking_timestamp' => current_time('mysql'), 'tracking_enabled' => false, 'message' => 'Performance tracking feature is not yet implemented' ]; } /** * Validate SEO settings (implements interface) * * @since 1.0.0 * * @param array $settings Settings array to validate * @return array Validation results */ public function validate_settings(array $settings): array { $validation = [ 'valid' => true, 'errors' => [], 'warnings' => [], 'suggestions' => [], 'score' => 100 ]; // Validate schema types configuration if (isset($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) { foreach ($settings['enabled_schema_types'] as $schema_type) { if (!isset($this->schema_types[$schema_type])) { $validation['errors'][] = "Invalid schema type: {$schema_type}"; $validation['valid'] = false; } } } // Note: Only JSON-LD deployment method is supported (no validation needed since it's hardcoded) // Validate auto-generation settings if (isset($settings['auto_generate_schema']) && !is_bool($settings['auto_generate_schema'])) { $validation['errors'][] = 'Auto-generate schema setting must be boolean'; $validation['valid'] = false; } // Validate validation requirements if (isset($settings['validation_level'])) { $valid_levels = ['strict', 'moderate', 'lenient']; if (!in_array($settings['validation_level'], $valid_levels, true)) { $validation['errors'][] = 'Invalid validation level specified'; $validation['valid'] = false; } } // Validate rich snippets optimization if (isset($settings['rich_snippets_optimization']) && !is_bool($settings['rich_snippets_optimization'])) { $validation['errors'][] = 'Rich snippets optimization setting must be boolean'; $validation['valid'] = false; } // Validate performance tracking if (isset($settings['performance_tracking']) && !is_bool($settings['performance_tracking'])) { $validation['errors'][] = 'Performance tracking setting must be boolean'; $validation['valid'] = false; } // Validate cache settings if (isset($settings['cache_duration'])) { if (!is_numeric($settings['cache_duration']) || $settings['cache_duration'] < 0) { $validation['errors'][] = 'Cache duration must be a positive number'; $validation['valid'] = false; } } // Calculate validation score $validation['score'] = $this->calculate_validation_score($validation); return $validation; } /** * Get output data for frontend rendering (implements interface) * * @since 1.0.0 * * @param string $context_type The context type * @param int|null $context_id Optional. Context ID * @return array Output data ready for frontend rendering */ public function get_output_data(string $context_type, ?int $context_id): array { $settings = $this->get_settings($context_type, $context_id); $output = [ 'schema_dashboard' => [], 'generated_schemas' => [], 'validation_results' => [], 'rich_snippets_preview' => [], 'performance_data' => [], 'recommendations' => [], // Report the real setting. Hardcoding true here told every consumer // the feature was on even when the master switch was off (#461). 'enabled' => (bool) ($settings['enabled'] ?? true) ]; // Get enabled schema types $enabled_types = $settings['enabled_schema_types'] ?? []; // Auto-generate schema types if enabled and no manual types specified if (empty($enabled_types) && ($settings['auto_generate_schema'] ?? true)) { $enabled_types = $this->auto_detect_schema_types($context_type, $context_id); } // Add content-specific schema types based on settings $enabled_types = $this->apply_content_schema_settings($enabled_types, $settings, $context_type); if (!empty($enabled_types)) { // Generate schema markup with enhanced options $generation_options = [ 'knowledge_graph' => $settings['knowledge_graph'] ?? true, 'rich_snippets_optimization' => $settings['rich_snippets_optimization'] ?? true, 'validation_level' => $settings['validation_level'] ?? 'moderate' ]; $generation_results = $this->generate_schema_markup($context_type, $context_id, $enabled_types, $generation_options); // Populate output data $output['generated_schemas'] = $generation_results['generated_schemas'] ?? []; $output['validation_results'] = $generation_results['validation_results'] ?? []; $output['rich_snippets_preview'] = $generation_results['rich_snippets_preview'] ?? []; $output['recommendations'] = $generation_results['optimization_recommendations'] ?? []; // Get schema dashboard data $output['schema_dashboard'] = [ 'total_schemas' => count($generation_results['generated_schemas'] ?? []), 'valid_schemas' => count(array_filter($generation_results['validation_results'] ?? [], function($v) { return $v['is_valid'] ?? false; })), 'deployment_ready' => $generation_results['deployment_ready'] ?? false, 'last_generated' => current_time('mysql') ]; // Get performance data if tracking enabled if ($settings['performance_tracking'] ?? false) { $output['performance_data'] = $this->track_schema_performance($context_type, $context_id); } } return $output; } // Removed knowledge graph enhancements - moved to separate service // Social links and enhanced data handled by Schema_Builder directly /** * Apply rich snippets optimization to schema data * * @since 1.0.0 * * @param array $schema_data Schema data * @param string $schema_type Schema type * @return array Optimized schema data */ private function apply_rich_snippets_optimization(array $schema_data, string $schema_type): array { switch ($schema_type) { case 'Article': case 'BlogPosting': // Rich snippets optimization for articles // Note: Image is optional - only include if user provides one // No default image fallback to avoid non-existent image URLs // Optimize headline length for rich snippets if (!empty($schema_data['headline']) && strlen($schema_data['headline']) > 110) { $schema_data['headline'] = substr($schema_data['headline'], 0, 107) . '...'; } break; case 'Organization': // Ensure logo for rich snippets if (empty($schema_data['logo'])) { $schema_data['logo'] = $this->get_default_organization_logo(); } break; case 'Product': // Ensure required properties for product rich snippets if (empty($schema_data['offers'])) { $schema_data['offers'] = [ '@type' => 'Offer', 'availability' => 'https://schema.org/InStock', 'priceCurrency' => 'USD' ]; } break; } return $schema_data; } /** * Apply content schema settings from options to enabled types * * @since 1.0.0 * * @param array $enabled_types Current enabled types * @param array $options Generation options * @param string $context_type Context type * @return array Enhanced enabled types */ private function apply_content_schema_settings_from_options(array $enabled_types, array $options, string $context_type): array { // For site context: only apply site-level schema settings if ($context_type === 'site') { // Add local business schema if enabled if ($options['enable_local_business'] ?? false) { if (!in_array('LocalBusiness', $enabled_types, true)) { $enabled_types[] = 'LocalBusiness'; } } return $enabled_types; } // For post/page context: apply all schema settings (metabox functionality) // Add article schema if enabled and context is appropriate if ($options['enable_article_schema'] ?? false) { if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) { $enabled_types[] = 'Article'; } } // Add FAQ schema if enabled if ($options['enable_faq_schema'] ?? false) { if (!in_array('FAQPage', $enabled_types, true)) { $enabled_types[] = 'FAQPage'; } } // Add How-To schema if enabled if ($options['enable_howto_schema'] ?? false) { if (!in_array('HowTo', $enabled_types, true)) { $enabled_types[] = 'HowTo'; } } // Add product schema if enabled and context is appropriate if ($options['enable_product_schema'] ?? false) { if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) { $enabled_types[] = 'Product'; } } // Add local business schema if enabled if ($options['enable_local_business'] ?? false) { if (!in_array('LocalBusiness', $enabled_types, true)) { $enabled_types[] = 'LocalBusiness'; } } return $enabled_types; } /** * Apply content schema settings to enabled types * * @since 1.0.0 * * @param array $enabled_types Current enabled types * @param array $settings Schema settings * @param string $context_type Context type * @return array Enhanced enabled types */ private function apply_content_schema_settings(array $enabled_types, array $settings, string $context_type): array { // For site context: only apply site-level schema settings if ($context_type === 'site') { // Add local business schema if enabled if ($settings['enable_local_business'] ?? false) { if (!in_array('LocalBusiness', $enabled_types, true)) { $enabled_types[] = 'LocalBusiness'; } } // Add breadcrumbs schema if enabled (site-wide feature) if ($settings['enable_breadcrumbs_schema'] ?? false) { if (!in_array('BreadcrumbList', $enabled_types, true)) { $enabled_types[] = 'BreadcrumbList'; } } return $enabled_types; } // For post/page context: apply all schema settings (metabox functionality) // Add article schema if enabled and context is appropriate if ($settings['enable_article_schema'] ?? false) { if (in_array($context_type, ['post', 'page'], true) && !in_array('Article', $enabled_types, true)) { $enabled_types[] = 'Article'; } } // Add FAQ schema if enabled if ($settings['enable_faq_schema'] ?? false) { if (!in_array('FAQPage', $enabled_types, true)) { $enabled_types[] = 'FAQPage'; } } // Add How-To schema if enabled if ($settings['enable_howto_schema'] ?? false) { if (!in_array('HowTo', $enabled_types, true)) { $enabled_types[] = 'HowTo'; } } // Add product schema if enabled and context is appropriate if ($settings['enable_product_schema'] ?? false) { if ($context_type === 'product' && !in_array('Product', $enabled_types, true)) { $enabled_types[] = 'Product'; } } // Add local business schema if enabled if ($settings['enable_local_business'] ?? false) { if (!in_array('LocalBusiness', $enabled_types, true)) { $enabled_types[] = 'LocalBusiness'; } } return $enabled_types; } /** * Get default organization logo for rich snippets * * @since 1.0.0 * * @return string Default logo URL */ private function get_default_organization_logo(): string { // Try to get custom logo $custom_logo_id = get_theme_mod('custom_logo'); if ($custom_logo_id) { $logo_url = wp_get_attachment_image_url($custom_logo_id, 'full'); if ($logo_url) { return $logo_url; } } // Fallback to site icon or default $site_icon_url = get_site_icon_url(); if ($site_icon_url) { return $site_icon_url; } // Final fallback return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg'); } /** * Schema keys outside the shared config defaults. * * @since 2.0.1 * * @return string[] */ protected function additional_setting_keys(): array { return [ 'enable_article_schema', 'enable_product_schema', 'enable_faq_schema', 'enable_howto_schema', ]; } /** * Per-entity schema fields are an open set. * * Each schema type the UI can edit contributes its own field family — * organization_*, person_*, website_*, business_*, software_*, howto_* — * and a new type adds another. The families this manager owns are matched * rather than enumerated, so adding a form does not silently start * dropping its fields (#452). * * @since 2.0.1 * * @return string[] */ protected function dynamic_setting_key_patterns(): array { return [ '/^organization_[a-z0-9_]+$/', '/^person_[a-z0-9_]+$/', '/^website_[a-z0-9_]+$/', '/^business_[a-z0-9_]+$/', '/^software_[a-z0-9_]+$/', '/^howto_[a-z0-9_]+$/', '/^product_[a-z0-9_]+$/', ]; } /** * Get default settings for a context type (implements interface) * * @since 1.0.0 * * @param string $context_type The context type to get defaults for * @return array Default settings array */ public function get_default_settings(string $context_type): array { return Schema_Settings_Config::get_default_settings($context_type); } /** * Get settings schema definition (implements interface) * * @since 1.0.0 * * @param string $context_type The context type to get schema for * @return array Settings schema definition */ public function get_settings_schema(string $context_type): array { return Schema_Settings_Config::get_settings_schema($context_type); } /** * Save SEO settings with cache invalidation and auto-deployment * * Overrides parent method to add schema cache invalidation when settings change. * This ensures cached schema data is refreshed when configuration changes. * Also triggers auto-deployment of schema when enabled. * * @since 1.0.0 * * @param string $context_type The context type * @param int|null $context_id Optional. Context ID * @param array $settings Settings array to save * @return bool True on success, false on failure */ public function save_settings(string $context_type, ?int $context_id, array $settings): bool { // Call parent method to save settings $success = parent::save_settings($context_type, $context_id, $settings); // CACHE INVALIDATION: Clear all schema cache when settings change if ($success && $this->cache_manager) { $this->cache_manager->invalidate_all_cache(); } // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change if ($success && !empty($settings['auto_deploy'])) { $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings); } return $success; } /** * Auto-deploy schema when settings change * * Automatically regenerates and deploys schema markup when organization or other * schema settings are modified, ensuring the frontend output stays in sync. * * @since 1.0.0 * * @param string $context_type Context type * @param int|null $context_id Context ID * @param array $settings Updated settings * @return void */ private function auto_deploy_schema_on_settings_change(string $context_type, ?int $context_id, array $settings): void { // Determine which schema types need to be regenerated based on changed settings $schema_types_to_regenerate = []; // Organization schema - regenerate if organization settings changed if ($this->has_organization_settings_changed($settings)) { $schema_types_to_regenerate[] = 'Organization'; } // Website schema - regenerate if website settings changed. The type is // registered as 'WebSite' (capital S) in Schema_Factory / $schema_types; // using 'Website' here made generate_schema_markup() silently skip it. if ($this->has_website_settings_changed($settings)) { $schema_types_to_regenerate[] = 'WebSite'; } // LocalBusiness schema - regenerate if business settings changed if ($this->has_business_settings_changed($settings)) { $schema_types_to_regenerate[] = 'LocalBusiness'; } // Person schema - regenerate if person settings changed if ($this->has_person_settings_changed($settings)) { $schema_types_to_regenerate[] = 'Person'; } // Honour the user's Schema Types selection. Without this the payload // shape alone decided what shipped, so every save deployed all four // types — including ones the user had explicitly deselected (#461). // An empty selection means "auto", so only filter when one is set. $enabled_types = $settings['enabled_schema_types'] ?? $this->get_settings($context_type, $context_id)['enabled_schema_types'] ?? []; if (!empty($enabled_types) && is_array($enabled_types)) { $schema_types_to_regenerate = array_values( array_intersect($schema_types_to_regenerate, $enabled_types) ); } // Types that were deployed but are no longer wanted must come back off // the page — deployment used to be additive-only (#464). $this->retire_unselected_schema_types($context_type, $context_id, $enabled_types); // If no schema types need regeneration, return early if (empty($schema_types_to_regenerate)) { return; } // Generate every affected type in ONE call. Generating them one at a // time re-entered store_schema_data() per type, and each pass replaced // the rows written by the previous one, so only the last type survived // (#454). One batch also means one delete and one cache flush. try { $generation_result = $this->generate_schema_markup( $context_type, $context_id, $schema_types_to_regenerate ); $deployable = []; foreach ($schema_types_to_regenerate as $schema_type) { // Only deploy what validated — see #470. if (!empty($generation_result['generated_schemas'][$schema_type]) && !empty($generation_result['validation_results'][$schema_type]['is_valid']) ) { $deployable[$schema_type] = $generation_result['generated_schemas'][$schema_type]; } } if (!empty($deployable)) { $this->deploy_schema_markup($context_type, $context_id, $deployable); } } catch (\Exception $e) { // Log error but don't fail the settings save if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log('ThinkRank: Auto-deploy failed for ' . implode(', ', $schema_types_to_regenerate) . ': ' . $e->getMessage()); } } } /** * Check if organization settings have changed * * @since 1.0.0 * * @param array $settings Updated settings * @return bool True if organization settings changed */ private function has_organization_settings_changed(array $settings): bool { $org_keys = [ 'organization_name', 'organization_type', 'organization_logo', 'organization_url', 'organization_description', 'organization_social_facebook', 'organization_social_twitter', 'organization_social_linkedin', 'organization_social_instagram', 'organization_social_youtube', 'organization_social_pinterest', 'organization_social_whatsapp', 'organization_social_telegram', 'organization_contact_type', 'organization_contact_phone', 'organization_contact_email', 'organization_contact_hours' ]; foreach ($org_keys as $key) { if (isset($settings[$key])) { return true; } } return false; } /** * Check if website settings have changed * * @since 1.0.0 * * @param array $settings Updated settings * @return bool True if website settings changed */ private function has_website_settings_changed(array $settings): bool { // These are the keys the Website tab actually stores. It previously // looked for site_name/site_description/site_url, which belong to Site // Identity and never appear in a schema settings payload — so WebSite // schema never auto-deployed no matter what was edited (#455). $website_keys = [ 'website_name', 'website_url', 'website_description', 'website_author', ]; foreach ($website_keys as $key) { if (isset($settings[$key])) { return true; } } return false; } /** * Check if business settings have changed * * @since 1.0.0 * * @param array $settings Updated settings * @return bool True if business settings changed */ private function has_business_settings_changed(array $settings): bool { // Only the keys this manager actually stores. business_name/address/ // phone/hours live in the site_identity category and never reach a // schema settings save, so keying off them meant LocalBusiness never // auto-deployed (#455). Edits to those fields refresh LocalBusiness // through the Site Identity save path instead — see // refresh_schema_for_foreign_settings(). $business_keys = [ 'enable_local_business', 'business_price_range', 'business_geo_latitude', 'business_geo_longitude', 'business_opening_hours', ]; foreach ($business_keys as $key) { if (isset($settings[$key])) { return true; } } return false; } /** * Check if person settings have changed * * @since 1.0.0 * * @param array $settings Updated settings * @return bool True if person settings changed */ private function has_person_settings_changed(array $settings): bool { $person_keys = ['person_name', 'person_image', 'person_job_title', 'person_description']; foreach ($person_keys as $key) { if (isset($settings[$key])) { return true; } } return false; } /** * Auto-detect appropriate schema types for context * * @since 1.0.0 * * @param string $context_type Context type * @param int|null $context_id Context ID * @return array Detected schema types */ private function auto_detect_schema_types(string $context_type, ?int $context_id): array { $detected_types = []; switch ($context_type) { case 'site': // The admin's Schema Types selection is the answer to "what // does this site need"; detection is only the fallback for an // install that has not chosen yet (#456). $settings = $this->get_settings($context_type, $context_id); $enabled = array_values(array_filter( array_map('strval', (array) ($settings['enabled_schema_types'] ?? [])), 'strlen' )); // Drop stale names the factory no longer registers rather than // handing them to the builder to silently skip. $enabled = array_values(array_filter( $enabled, fn($type) => isset($this->schema_types[$type]) )); if (!empty($enabled)) { $detected_types = $enabled; break; } $detected_types = ['Organization']; // Check if it's a local business if ($this->is_local_business()) { $detected_types[] = 'LocalBusiness'; } break; case 'post': $detected_types = ['Article']; // Check content type for specific article types if ($context_id) { $post = get_post($context_id); if ($post && $this->is_how_to_content($post->post_content)) { $detected_types[] = 'HowTo'; } } break; case 'page': $detected_types = ['Article']; if ($context_id) { $page = get_post($context_id); if ($page && $this->is_faq_content($page->post_content)) { $detected_types[] = 'FAQPage'; } } break; case 'product': $detected_types = ['Product']; break; } return $detected_types; } /** * Prepare content data for Schema_Builder * * @since 1.0.0 * * @param string $context_type Context type * @param int|null $context_id Context ID * @param array $content_analysis Content analysis data * @param array $optimization_data Optimization data * @param array $options Generation options (may contain custom content_data) * @return array Prepared content data for schema generation */ private function prepare_content_data_for_generator(string $context_type, ?int $context_id, array $content_analysis, array $optimization_data, array $options = []): array { $content_data = []; // Handle different context types if ($context_type === 'site') { // Site-level data $content_data = [ 'title' => get_bloginfo('name'), 'url' => home_url(), 'excerpt' => get_bloginfo('description'), 'content' => get_bloginfo('description'), 'business_data' => $this->get_business_data_from_local_seo(), 'site_data' => $this->get_site_data_for_schema(), 'social_data' => $this->get_social_data_for_schema() ]; } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) { // Post/page/product data $post = get_post($context_id); if ($post) { $content_data = [ 'title' => $post->post_title, 'url' => get_permalink($post->ID), 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30), 'content' => $post->post_content, 'author' => [ 'name' => get_the_author_meta('display_name', $post->post_author), 'url' => get_author_posts_url($post->post_author) ], // ISO 8601 with offset. post_date/post_modified are raw // MySQL columns in site-local time with no timezone, which // Google rejects as "Invalid value in field datePublished" // and drops the Article rich result (#465). 'date' => get_the_date('c', $post), 'modified' => get_the_modified_date('c', $post), 'image' => get_the_post_thumbnail_url($post->ID, 'full'), 'focus_keywords' => Focus_Keywords::get($post->ID), 'business_data' => $this->get_business_data_from_local_seo(), 'site_data' => $this->get_site_data_for_schema(), 'social_data' => $this->get_social_data_for_schema() ]; // Override with custom content data if provided (for metabox usage) if (!empty($options['content_data'])) { $custom_data = $options['content_data']; // Override title if provided and not empty if (!empty($custom_data['title'])) { $content_data['title'] = $custom_data['title']; } // Override excerpt/description if provided and not empty if (!empty($custom_data['description'])) { $content_data['excerpt'] = $custom_data['description']; } // Override content if provided and not empty if (!empty($custom_data['content'])) { $content_data['content'] = $custom_data['content']; } // Override URL if provided and not empty, but ensure it's the post permalink, not admin URL if (!empty($custom_data['post_url'])) { // If the URL is an admin edit URL, convert it to the post permalink if (strpos($custom_data['post_url'], 'wp-admin/post.php') !== false && $context_id) { $content_data['url'] = get_permalink($context_id); } else { $content_data['url'] = $custom_data['post_url']; } } // Add focus keyword(s) if provided if (!empty($custom_data['focus_keywords']) && is_array($custom_data['focus_keywords'])) { $content_data['focus_keywords'] = $custom_data['focus_keywords']; } if (!empty($custom_data['focus_keyword'])) { $content_data['focus_keyword'] = $custom_data['focus_keyword']; } // Add word count if provided (from frontend calculation) if (!empty($custom_data['word_count'])) { $content_data['word_count'] = (int) $custom_data['word_count']; } // CRITICAL: Override site_data fields that take precedence in schema builder // The schema builder checks site_data first, so we need to clear these // to ensure our custom data is used instead if (isset($content_data['site_data'])) { // Clear site-level article settings so custom data takes precedence unset($content_data['site_data']['article_headline']); unset($content_data['site_data']['article_description']); unset($content_data['site_data']['article_author']); } } // Merge schema form data into site_data if provided (for content-specific schemas) if (!empty($options['schema_form_data'])) { $form_data = $options['schema_form_data']; // Ensure site_data exists if (!isset($content_data['site_data'])) { $content_data['site_data'] = []; } // Merge form data into site_data so schema builder can access it $content_data['site_data'] = array_merge($content_data['site_data'], $form_data); } // Simplified: Content analysis moved to separate services // Word count and reading time handled by Schema_Builder directly from content } } return $content_data; } /** * Store schema data in database * * @since 1.0.0 * * @param string $context_type Context type * @param int|null $context_id Context ID * @param array $generation Generation results * @return bool Success status */ private function store_schema_data(string $context_type, ?int $context_id, array $generation): bool { global $wpdb; $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; // Replace only the types in this batch. Clearing the whole context // destroyed types the caller never asked about — and callers do // regenerate a subset, one type at a time (#454). $generated_types = array_keys($generation['generated_schemas'] ?? []); if (empty($generated_types)) { return false; } $this->delete_existing_schemas($context_type, $context_id, $generated_types); foreach ($generation['generated_schemas'] as $schema_type => $schema_data) { // Prepare schema data with validation status embedded $schema_data_with_validation = $schema_data; $schema_data_with_validation['_validation'] = [ 'is_valid' => $generation['validation_results'][$schema_type]['is_valid'], 'errors' => $generation['validation_results'][$schema_type]['errors'] ?? [], 'warnings' => $generation['validation_results'][$schema_type]['warnings'] ?? [], 'score' => $generation['validation_results'][$schema_type]['validation_score'] ?? 0 ]; $data = [ 'context_type' => $context_type, 'context_id' => $context_id, 'schema_type' => $schema_type, 'schema_data' => wp_json_encode($schema_data_with_validation), 'validation_status' => $generation['validation_results'][$schema_type]['is_valid'] ? 'valid' : 'invalid', // Per-type, not batch-wide. deployment_ready is only true when // EVERY type in the batch validated, so one invalid type (a site // with no Business Info makes LocalBusiness invalid) deactivated // all the valid ones alongside it (#470). 'is_active' => !empty($generation['validation_results'][$schema_type]['is_valid']) ? 1 : 0 ]; // Insert new schema (existing ones were already deleted) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access $wpdb->insert($table_name, $data); } // CACHE INVALIDATION: Clear cache after storing new schema data if ($this->cache_manager) { $this->cache_manager->invalidate_context_cache($context_type, $context_id); } return true; } /** * Calculate validation score * * @since 1.0.0 * * @param array $validation Validation results * @return int Score (0-100) */ private function calculate_validation_score(array $validation): int { $score = 100; $score -= count($validation['errors'] ?? []) * 20; $score -= count($validation['warnings'] ?? []) * 10; $score -= count($validation['suggestions'] ?? []) * 5; return max(0, (int) round($score)); } /** * Simple implementations for helper methods referenced in the main functions * These would be enhanced with more sophisticated algorithms in production */ // Removed complex AI integration methods - moved to separate services // Schema management focuses on core structured data generation private function generate_rich_snippets_preview(array $schema_data, string $schema_type): array { return [ 'preview_type' => $schema_type, 'title' => $schema_data['headline'] ?? $schema_data['name'] ?? 'Title', 'description' => $schema_data['description'] ?? 'Description', 'image' => $schema_data['image']['url'] ?? $schema_data['image'] ?? null, 'additional_info' => $this->extract_additional_info($schema_data, $schema_type) ]; } private function extract_additional_info(array $schema_data, string $schema_type): array { $info = []; switch ($schema_type) { case 'Article': if (isset($schema_data['author']['name'])) { $info['author'] = $schema_data['author']['name']; } if (isset($schema_data['datePublished'])) { $timestamp = strtotime($schema_data['datePublished']); if ($timestamp !== false) { $info['date'] = gmdate('M j, Y', $timestamp); } } break; case 'Product': if (isset($schema_data['offers']['price'])) { $info['price'] = $schema_data['offers']['priceCurrency'] . $schema_data['offers']['price']; } if (isset($schema_data['brand']['name'])) { $info['brand'] = $schema_data['brand']['name']; } break; } return $info; } private function generate_schema_optimization_recommendations(array $generated_schemas, array $validation_results): array { $recommendations = []; foreach ($validation_results as $schema_type => $validation) { if (!$validation['is_valid']) { $recommendations[] = [ 'type' => 'validation_error', 'schema_type' => $schema_type, 'priority' => 'high', 'message' => "Schema validation failed for {$schema_type}", 'action' => 'Fix validation errors before deployment' ]; } if (!empty($validation['warnings'])) { // Extract missing properties from warnings $missing_properties = []; foreach ($validation['warnings'] as $warning) { if (strpos($warning, 'Missing recommended property:') === 0) { $property = trim(str_replace('Missing recommended property:', '', $warning)); $missing_properties[] = $property; } } if (!empty($missing_properties)) { $properties_list = implode(', ', $missing_properties); $recommendations[] = [ 'type' => 'missing_properties', 'schema_type' => $schema_type, 'priority' => 'medium', 'message' => "Missing recommended properties for {$schema_type}: {$properties_list}", 'action' => 'Add these properties to improve rich snippets eligibility' ]; } else { $recommendations[] = [ 'type' => 'missing_properties', 'schema_type' => $schema_type, 'priority' => 'medium', 'message' => "Missing recommended properties for {$schema_type}", 'action' => 'Add recommended properties to improve rich snippets eligibility' ]; } } } return $recommendations; } private function check_deployment_readiness(array $validation_results): bool { foreach ($validation_results as $validation) { if (!$validation['is_valid']) { return false; } } return true; } // Content detection helper methods private function is_local_business(): bool { // Simple check - would be enhanced with actual business detection $description = get_bloginfo('description'); $local_keywords = ['restaurant', 'shop', 'store', 'clinic', 'office', 'service']; foreach ($local_keywords as $keyword) { if (stripos($description, $keyword) !== false) { return true; } } return false; } private function is_how_to_content(string $content): bool { $how_to_keywords = ['step', 'how to', 'tutorial', 'guide', 'instructions']; $content_lower = strtolower($content); foreach ($how_to_keywords as $keyword) { if (stripos($content_lower, $keyword) !== false) { return true; } } return false; } private function is_faq_content(string $content): bool { $faq_keywords = ['faq', 'frequently asked', 'questions', 'q:', 'a:']; $content_lower = strtolower($content); foreach ($faq_keywords as $keyword) { if (stripos($content_lower, $keyword) !== false) { return true; } } return false; } private function determine_deployment_method(array $options): string { // Always use JSON-LD as it's the only supported method return 'json_ld'; } private function deploy_single_schema(array $schema, string $schema_type, string $method, string $context_type, ?int $context_id): array { global $wpdb; // Use existing seo_schema table $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; $deployment_data = [ 'context_type' => $context_type, 'context_id' => $context_id, 'schema_type' => $schema_type, 'schema_data' => wp_json_encode($schema), 'validation_status' => 'deployed', 'is_active' => 1 ]; // Check if schema already exists for this context and type if (null === $context_id) { // Handle NULL context_id case // 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 $sql = sprintf( 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id IS NULL AND schema_type = %%s', $table_name ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access $existing = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders $sql, $context_type, $schema_type ) ); } else { // Handle regular context_id case // 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 $sql = sprintf( 'SELECT schema_id FROM %s WHERE context_type = %%s AND context_id = %%d AND schema_type = %%s', $table_name ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deployment requires direct database access $existing = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders $sql, $context_type, $context_id, $schema_type ) ); } if ($existing) { // Update existing deployment // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema update requires direct database access $result = $wpdb->update( $table_name, [ 'schema_data' => wp_json_encode($schema), 'validation_status' => 'deployed', 'is_active' => 1, 'updated_at' => current_time('mysql') ], ['schema_id' => $existing], ['%s', '%s', '%d', '%s'], ['%d'] ); } else { // Insert new deployment // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema insertion requires direct database access $result = $wpdb->insert( $table_name, $deployment_data, ['%s', '%d', '%s', '%s', '%s', '%d'] ); } return [ 'deployed' => $result !== false, 'method' => $method, 'schema_type' => $schema_type, 'schema_id' => $existing ?: $wpdb->insert_id ]; } /** * Get deployed schemas for frontend integration * * PERFORMANCE OPTIMIZED: This method now uses: * 1. Schema caching layer (90% reduction in database queries) * 2. Window function approach instead of correlated subquery (80-90% query performance improvement) * 3. Composite index: idx_context_schema_active * * @since 1.0.0 * * @param string $context_type Context type * @param int|null $context_id Context ID * @return array Deployed schemas for current context */ public function get_deployed_schemas(string $context_type = 'site', ?int $context_id = null): array { // CACHE LAYER: Check cache first for immediate 90% performance improvement if ($this->cache_manager) { $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id); $cached_data = $this->cache_manager->get($cache_key); if ($cached_data !== null) { // CACHE FIX: Extract actual data from cache wrapper return isset($cached_data['data']) ? $cached_data['data'] : $cached_data; } } global $wpdb; // Use existing seo_schema table $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery // This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC) if (null === $context_id) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access $sql = sprintf( 'SELECT schema_type, schema_data FROM (SELECT schema_type, schema_data, ROW_NUMBER() OVER (PARTITION BY schema_type ORDER BY created_at DESC) as rn FROM %s WHERE context_type = %%s AND context_id IS NULL AND is_active = 1 AND validation_status IN (\'deployed\', \'valid\')) ranked WHERE rn = 1 ORDER BY schema_type', $table_name ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access $deployed_schemas = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders $sql, $context_type ), ARRAY_A ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access $sql = sprintf( 'SELECT schema_type, schema_data FROM (SELECT schema_type, schema_data, ROW_NUMBER() OVER (PARTITION BY schema_type ORDER BY created_at DESC) as rn FROM %s WHERE context_type = %%s AND context_id = %%d AND is_active = 1 AND validation_status IN (\'deployed\', \'valid\')) ranked WHERE rn = 1 ORDER BY schema_type', $table_name ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access $deployed_schemas = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders $sql, $context_type, $context_id ), ARRAY_A ); } // Deliberately no early return on an empty result: it has to reach the // cache write below. Most URLs have no deployed schema, so gating the // write on a non-empty result made the majority of front-end requests // permanent cache misses, re-running a ROW_NUMBER() OVER (PARTITION BY // ...) query with two filesorts on every pageview (#392). $deployed_schemas = $deployed_schemas ?: []; // Process schemas for return $processed_schemas = []; foreach ($deployed_schemas as $deployed_schema) { $schema_data = json_decode($deployed_schema['schema_data'], true); $schema_type = $deployed_schema['schema_type']; if (!empty($schema_data)) { // Remove internal validation metadata before frontend output if (isset($schema_data['_validation'])) { unset($schema_data['_validation']); } // Deployed schema is a snapshot, so rows written before #465 // still carry raw MySQL datetimes. Normalise on read so the // fix reaches existing sites without a migration. $schema_data = $this->normalize_stored_schema($schema_data); // The permalink was frozen at deploy time, so schema deployed // while a post was a draft advertised "?p=123" as both url and // mainEntityOfPage forever — contradicting the node's own @id // and the canonical (#470). Resolve it live instead. $schema_data = $this->refresh_schema_permalink($schema_data, $context_type, $context_id); // schema.org types `sameAs`, `url`, `logo` and `image` as URLs, // but the form stored whatever was typed, so free text entered // in a social-profile field shipped as a sameAs member and made // the whole entity invalid (#480). Drop bad values on read, so // existing sites stop emitting them without a migration. $schema_data = $this->filter_entity_urls($schema_data); $processed_schemas[$schema_type] = [ 'data' => $schema_data, 'method' => 'json_ld', // Default method 'type' => $schema_type ]; } } // CACHE LAYER: Store result in cache for future requests — including // an empty one. Cache_Manager::set() wraps the payload in a metadata // envelope, so an empty result is still stored as a truthy value and // reads back as a hit rather than a miss (#392). if ($this->cache_manager) { $cache_key = $this->cache_manager->generate_deployed_schemas_key($context_type, $context_id); $this->cache_manager->set($cache_key, $processed_schemas); } return $processed_schemas; } /** * Properties schema.org defines as URLs. * * @since 2.0.2 * @var string[] */ private const URL_PROPERTIES = ['sameAs', 'url', 'logo', 'image']; /** * Whether a value is a URL safe to publish in structured data. * * @since 2.0.2 * * @param mixed $url Candidate value. * @return bool */ private function is_publishable_url($url): bool { if (!is_string($url) || '' === trim($url)) { return false; } if (!filter_var($url, FILTER_VALIDATE_URL)) { return false; } $scheme = wp_parse_url($url, PHP_URL_SCHEME); return in_array(strtolower((string) $scheme), ['http', 'https'], true); } /** * Drop values that are not URLs from URL-typed properties. * * An absent property is valid; one holding free text is not, and it can * invalidate the entity around it. Nested objects (`logo` and `image` are * frequently ImageObjects) are walked so a bad `url` inside one is caught * too. A property left with nothing is removed rather than emitted empty. * * @since 2.0.2 * * @param array $schema Decoded schema data. * @return array Schema carrying only publishable URLs. */ private function filter_entity_urls(array $schema): array { foreach ($schema as $key => $value) { if (is_array($value) && !in_array($key, self::URL_PROPERTIES, true)) { $schema[$key] = $this->filter_entity_urls($value); continue; } if (!in_array($key, self::URL_PROPERTIES, true)) { continue; } // A nested object (ImageObject and friends) carries its own url. if (is_array($value) && isset($value['@type'])) { $schema[$key] = $this->filter_entity_urls($value); continue; } if (is_array($value)) { $kept = []; foreach ($value as $item) { if (is_array($item)) { $kept[] = $this->filter_entity_urls($item); } elseif ($this->is_publishable_url($item)) { $kept[] = $item; } } if ([] === $kept) { unset($schema[$key]); } else { $schema[$key] = array_values($kept); } continue; } if (!$this->is_publishable_url($value)) { unset($schema[$key]); } } return $schema; } /** * Schema types whose `url` identifies the entity, not the page. * * On a Person or an Organization, `url` is that entity's own website, so * overwriting it with the permalink of whichever post the schema happens to * be deployed on is simply wrong. It also breaks graph assembly: the site * identity emits the same entity with its real `url`, and once the two * copies disagree they can no longer be recognised as one entity (#479). * * @since 2.0.2 * @var string[] */ private const ENTITY_URL_TYPES = ['Person', 'Organization', 'LocalBusiness']; /** * Replace a stored permalink snapshot with the post's live permalink. * * Only touches `url` and `mainEntityOfPage`, and only for post-like * contexts where a permalink actually exists. Identity entities are * exempt from the `url` rewrite — see self::ENTITY_URL_TYPES. * * @since 1.16.0 * * @param array $schema Decoded schema data. * @param string $context_type Context type. * @param int|null $context_id Context ID. * @return array Schema with a current permalink. */ private function refresh_schema_permalink(array $schema, string $context_type, ?int $context_id): array { if ('site' === $context_type || empty($context_id)) { return $schema; } $permalink = get_permalink($context_id); if (!$permalink) { return $schema; } $type = $schema['@type'] ?? ''; $type = is_array($type) ? reset($type) : $type; $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true); if (isset($schema['url']) && !$is_entity) { $schema['url'] = $permalink; } if (isset($schema['mainEntityOfPage'])) { if (is_array($schema['mainEntityOfPage'])) { if (isset($schema['mainEntityOfPage']['@id'])) { $schema['mainEntityOfPage']['@id'] = $permalink; } } else { $schema['mainEntityOfPage'] = $permalink; } } return $schema; } /** * Normalise properties that stored snapshots may hold in a stale format. * * Deployed schema is written once and read forever, so a formatting fix in * the builder never reaches rows already on disk. Correcting on read means * existing sites benefit without a migration. * * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values * inside author/publisher/@graph entries are covered too. * * @since 1.16.0 * * @param array $schema Decoded schema data. * @return array Normalised schema. */ private function normalize_stored_schema(array $schema): array { static $date_keys = [ 'datePublished', 'dateModified', 'dateCreated', 'uploadDate', 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires', ]; foreach ($schema as $key => $value) { if (is_array($value)) { $schema[$key] = $this->normalize_stored_schema($value); continue; } if ('inLanguage' === $key && is_string($value) && '' !== $value) { $schema[$key] = str_replace('_', '-', $value); continue; } if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) { continue; } // Already ISO 8601 — leave it alone. if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $value)) { continue; } $timestamp = strtotime($value); if (false !== $timestamp) { $schema[$key] = (string) wp_date('c', $timestamp); } } return $schema; } /** * Clean up duplicate schemas in database * * @since 1.0.0 * * @param string $context_type Context type * @param int|null $context_id Context ID * @return int Number of duplicate schemas removed */ public function cleanup_duplicate_schemas(string $context_type = 'site', ?int $context_id = null): int { global $wpdb; $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; if (null === $context_id) { // Clean up duplicates for NULL context_id // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access $sql = sprintf( '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', $table_name, $table_name ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access $deleted = $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders $sql, $context_type, $context_type ) ); } else { // Clean up duplicates for specific context_id // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access $sql = sprintf( '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', $table_name, $table_name ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema cleanup requires direct database access $deleted = $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders $sql, $context_type, $context_id, $context_type, $context_id ) ); } return $deleted ?: 0; } /** * Deactivate deployed schema rows for the given types. * * Deployment was insert-only, so anything ever deployed to a context stayed * on the page forever — switching a post's schema type left the old one live * and deactivating a saved schema did nothing (#464). Rows are deactivated * rather than deleted so a later redeploy can revive them and so there is a * trail of what was published. * * @since 1.16.0 * * @param string $context_type Context type. * @param int|null $context_id Context ID. * @param string[] $schema_types Types to retire. * @return int Number of rows deactivated. */ private function retire_schema_types(string $context_type, ?int $context_id, array $schema_types): int { $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen')); if (empty($schema_types)) { return 0; } global $wpdb; $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; $placeholders = implode(', ', array_fill(0, count($schema_types), '%s')); if (null === $context_id) { $sql = sprintf( 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id IS NULL AND schema_type IN (%s)', $table_name, $placeholders ); $args = array_merge([$context_type], $schema_types); } else { $sql = sprintf( 'UPDATE %s SET is_active = 0 WHERE context_type = %%s AND context_id = %%d AND schema_type IN (%s)', $table_name, $placeholders ); $args = array_merge([$context_type, $context_id], $schema_types); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Retiring deployed schema rows requires direct database access. $updated = $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is built from an internal table name and generated placeholders. $sql, $args ) ); if ($updated && $this->cache_manager) { $this->cache_manager->invalidate_context_cache($context_type, $context_id); } return (int) ($updated ?: 0); } /** * Retire deployed types that are no longer in the user's Schema Types selection. * * An empty selection means "auto-detect", so nothing is retired in that case. * * @since 1.16.0 * * @param string $context_type Context type. * @param int|null $context_id Context ID. * @param array $enabled_types The user's selected types. * @return int Number of rows deactivated. */ private function retire_unselected_schema_types(string $context_type, ?int $context_id, array $enabled_types): int { if (empty($enabled_types)) { return 0; } $deployed = array_keys($this->get_deployed_schemas($context_type, $context_id)); $stale = array_diff($deployed, $enabled_types); return $this->retire_schema_types($context_type, $context_id, $stale); } /** * Delete stored schemas for a context before storing new ones. * * `$schema_types` scopes the delete to the types actually being rewritten. * Without it this wiped every type in the context, which silently destroyed * deployed schema whenever a caller regenerated a subset — and * auto_deploy_schema_on_settings_change() regenerates one type at a time * (#454). Passing an empty array keeps the original clear-the-context * behaviour for callers that genuinely rewrite everything. * * @since 1.0.0 * * @param string $context_type Context type * @param int|null $context_id Context ID * @param string[] $schema_types Optional. Limit the delete to these types. * @return int Number of schemas deleted */ private function delete_existing_schemas(string $context_type, ?int $context_id, array $schema_types = []): int { global $wpdb; $table_name = $wpdb->prefix . 'thinkrank_seo_schema'; // Build an optional `AND schema_type IN (…)` clause with one prepared // placeholder per type, so the scoping cannot be injected through. $type_clause = ''; $type_values = []; $schema_types = array_values(array_filter(array_map('strval', $schema_types), 'strlen')); if (!empty($schema_types)) { $type_clause = ' AND schema_type IN (' . implode(', ', array_fill(0, count($schema_types), '%s')) . ')'; $type_values = $schema_types; } if (null === $context_id) { // Delete all schemas for NULL context_id // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access $sql = sprintf( 'DELETE FROM %s WHERE context_type = %%s AND context_id IS NULL%s', $table_name, $type_clause ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access $deleted = $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders $sql, array_merge([$context_type], $type_values) ) ); } else { // Delete all schemas for specific context_id // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access $sql = sprintf( 'DELETE FROM %s WHERE context_type = %%s AND context_id = %%d%s', $table_name, $type_clause ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema deletion requires direct database access $deleted = $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders $sql, array_merge([$context_type, $context_id], $type_values) ) ); } return $deleted ?: 0; } /** * Get business data from Site Identity Local settings * * @return array */ private function get_business_data_from_local_seo(): array { // Get Site Identity settings which include Local SEO data $site_identity_settings = get_option('thinkrank_site_identity_settings', []); return [ 'business_name' => $site_identity_settings['business_name'] ?? '', 'business_address' => $site_identity_settings['business_address'] ?? '', 'business_city' => $site_identity_settings['business_city'] ?? '', 'business_state' => $site_identity_settings['business_state'] ?? '', 'business_postal_code' => $site_identity_settings['business_postal_code'] ?? '', 'business_country' => $site_identity_settings['business_country'] ?? '', 'business_phone' => $site_identity_settings['business_phone'] ?? '', 'business_email' => $site_identity_settings['business_email'] ?? '', 'business_hours' => $site_identity_settings['business_hours'] ?? [], 'business_type' => $site_identity_settings['business_type'] ?? 'LocalBusiness' ]; } public function get_settings(string $context_type, ?int $context_id = null): array { // Get base settings from parent $settings = parent::get_settings($context_type, $context_id); // For site context, automatically include Site Identity data if ($context_type === 'site') { // Get Site Identity settings from the Site Identity Manager $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager(); $site_identity_settings = $site_identity_manager->get_settings('site', null); // Include Site Identity assets if not already set in Schema Manager if (empty($settings['logo_url']) && !empty($site_identity_settings['logo_url'])) { $settings['logo_url'] = $site_identity_settings['logo_url']; } if (empty($settings['favicon_url']) && !empty($site_identity_settings['favicon_url'])) { $settings['favicon_url'] = $site_identity_settings['favicon_url']; } if (empty($settings['apple_touch_icon_url']) && !empty($site_identity_settings['apple_touch_icon_url'])) { $settings['apple_touch_icon_url'] = $site_identity_settings['apple_touch_icon_url']; } // Include Site Identity organization data if not already set in Schema Manager if (empty($settings['organization_name']) && !empty($site_identity_settings['site_name'])) { $settings['organization_name'] = $site_identity_settings['site_name']; } if (empty($settings['organization_url']) && !empty($site_identity_settings['site_url'])) { $settings['organization_url'] = $site_identity_settings['site_url']; } if (empty($settings['organization_description']) && !empty($site_identity_settings['site_description'])) { $settings['organization_description'] = $site_identity_settings['site_description']; } } return $settings; } /** * Get site data for rich schema generation * * @return array */ private function get_site_data_for_schema(): array { // Get Schema Manager's own settings first (highest priority) $schema_settings = $this->get_settings('site', null); // Get Site Identity settings for additional data $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager(); $site_identity_settings = $site_identity_manager->get_settings('site', null); return [ 'site_name' => get_bloginfo('name'), 'site_description' => get_bloginfo('description'), 'site_url' => home_url(), 'admin_email' => get_option('admin_email'), 'language' => get_locale(), 'timezone' => get_option('timezone_string'), 'founded_date' => $site_identity_settings['founded_date'] ?? '', 'founder_name' => $site_identity_settings['founder_name'] ?? '', 'company_type' => $site_identity_settings['company_type'] ?? 'Organization', // Site Identity assets 'logo_url' => $site_identity_settings['logo_url'] ?? '', 'favicon_url' => $site_identity_settings['favicon_url'] ?? '', // Schema Manager organization settings (highest priority) 'organization_name' => $schema_settings['organization_name'] ?? '', 'organization_description' => $schema_settings['organization_description'] ?? '', 'organization_url' => $schema_settings['organization_url'] ?? '', // Removed post/page-specific schema settings (Product, Event, Article, Software Application) // These are now handled only at the post/page level via metabox // Person schema settings (site-wide) 'person_name' => $schema_settings['person_name'] ?? '', 'person_job_title' => $schema_settings['person_job_title'] ?? '', 'person_description' => $schema_settings['person_description'] ?? '', 'person_image' => $schema_settings['person_image'] ?? '', 'person_url' => $schema_settings['person_url'] ?? '', 'person_email' => $schema_settings['person_email'] ?? '', 'person_telephone' => $schema_settings['person_telephone'] ?? '', 'person_address' => $schema_settings['person_address'] ?? '', 'person_birth_date' => $schema_settings['person_birth_date'] ?? '', 'person_nationality' => $schema_settings['person_nationality'] ?? '', 'person_works_for' => $schema_settings['person_works_for'] ?? '', 'person_same_as' => $schema_settings['person_same_as'] ?? [], // Website schema settings (site-wide) 'website_name' => $schema_settings['website_name'] ?? '', 'website_url' => $schema_settings['website_url'] ?? '', 'website_description' => $schema_settings['website_description'] ?? '', 'website_author' => $schema_settings['website_author'] ?? '', 'organization_logo' => $schema_settings['organization_logo'] ?? '', // Social media links (sameAs) 'organization_social_facebook' => $schema_settings['organization_social_facebook'] ?? '', 'organization_social_twitter' => $schema_settings['organization_social_twitter'] ?? '', 'organization_social_linkedin' => $schema_settings['organization_social_linkedin'] ?? '', 'organization_social_instagram' => $schema_settings['organization_social_instagram'] ?? '', 'organization_social_youtube' => $schema_settings['organization_social_youtube'] ?? '', 'organization_social_pinterest' => $schema_settings['organization_social_pinterest'] ?? '', 'organization_social_whatsapp' => $schema_settings['organization_social_whatsapp'] ?? '', 'organization_social_telegram' => $schema_settings['organization_social_telegram'] ?? '', // Contact point information 'organization_contact_type' => $schema_settings['organization_contact_type'] ?? 'customer service', 'organization_contact_phone' => $schema_settings['organization_contact_phone'] ?? '', 'organization_contact_email' => $schema_settings['organization_contact_email'] ?? '', 'organization_contact_hours' => $schema_settings['organization_contact_hours'] ?? '', // LocalBusiness specific fields 'business_price_range' => $schema_settings['business_price_range'] ?? '', 'business_geo_latitude' => $schema_settings['business_geo_latitude'] ?? '', 'business_geo_longitude' => $schema_settings['business_geo_longitude'] ?? '', 'business_opening_hours' => $schema_settings['business_opening_hours'] ?? [] ]; } /** * Get social media data for schema generation * * @return array */ private function get_social_data_for_schema(): array { // Get Social Media settings $social_settings = get_option('thinkrank_social_media_settings', []); $social_profiles = []; // Common social platforms $platforms = ['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'tiktok', 'pinterest']; foreach ($platforms as $platform) { $url = $social_settings["{$platform}_url"] ?? ''; if (!empty($url)) { $social_profiles[] = $url; } } return [ 'social_profiles' => $social_profiles, 'social_settings' => $social_settings ]; } }