*/ private static $sync_fields = array( 'same_as' => 'entity_same_as', 'knows_about' => 'entity_knows_about', 'legal_name' => 'entity_legal_name', 'founding_date' => 'entity_founding_date', 'description' => 'entity_description', 'num_employees' => 'entity_num_employees', 'founders' => 'entity_founders', 'tax_id' => 'entity_tax_id', 'vat_id' => 'entity_vat_id', 'site_type' => 'site_type', 'sub_type' => 'business_subtype', ); /** * Schema types deprecated by Google in January 2026. * * @var string[] */ public static $deprecated_types = array( 'PracticeProblem', 'NearbyOffers', 'MathSolver', 'CovidTestingFacility', 'SpecialAnnouncement', 'Certification', 'HomeAndConstructionBusiness', ); /** * Get all entity settings from the plugin option. * * @return array */ public static function get_all() { $settings = get_option( 'schema_ai_settings', array() ); return array( 'same_as' => isset( $settings['entity_same_as'] ) && is_array( $settings['entity_same_as'] ) ? array_filter( array_map( 'esc_url_raw', $settings['entity_same_as'] ) ) : array(), 'knows_about' => isset( $settings['entity_knows_about'] ) && is_array( $settings['entity_knows_about'] ) ? array_values( array_filter( array_map( array( __CLASS__, 'sanitize_topic' ), $settings['entity_knows_about'] ) ) ) : array(), 'legal_name' => isset( $settings['entity_legal_name'] ) ? sanitize_text_field( $settings['entity_legal_name'] ) : '', 'founding_date' => isset( $settings['entity_founding_date'] ) ? sanitize_text_field( $settings['entity_founding_date'] ) : '', 'description' => isset( $settings['entity_description'] ) ? sanitize_textarea_field( $settings['entity_description'] ) : '', 'num_employees' => isset( $settings['entity_num_employees'] ) ? sanitize_text_field( $settings['entity_num_employees'] ) : '', 'founders' => isset( $settings['entity_founders'] ) && is_array( $settings['entity_founders'] ) ? array_values( array_filter( array_map( array( __CLASS__, 'sanitize_founder' ), $settings['entity_founders'] ) ) ) : array(), 'tax_id' => isset( $settings['entity_tax_id'] ) ? sanitize_text_field( $settings['entity_tax_id'] ) : '', 'vat_id' => isset( $settings['entity_vat_id'] ) ? sanitize_text_field( $settings['entity_vat_id'] ) : '', 'site_type' => isset( $settings['site_type'] ) ? sanitize_text_field( $settings['site_type'] ) : '', 'sub_type' => isset( $settings['business_subtype'] ) ? sanitize_text_field( $settings['business_subtype'] ) : '', ); } /** * Sanitize a single founder entry: { name, jobTitle?, sameAs? }. * Returns null for anything without a name (dropped by the caller). * Omits jobTitle/sameAs entirely when blank, rather than storing empty * strings that would otherwise leak into the generated Person schema. * * @param mixed $founder * @return array{name:string,jobTitle?:string,sameAs?:string}|null */ private static function sanitize_founder( $founder ) { if ( ! is_array( $founder ) || empty( $founder['name'] ) ) { return null; } $out = array( 'name' => sanitize_text_field( $founder['name'] ) ); if ( ! empty( $founder['jobTitle'] ) ) { $out['jobTitle'] = sanitize_text_field( $founder['jobTitle'] ); } if ( ! empty( $founder['sameAs'] ) ) { $out['sameAs'] = esc_url_raw( $founder['sameAs'] ); } return $out; } /** * Sanitize a single knowsAbout topic. Plain strings are the legacy shape * (no Wikidata match); an array with wikidataId/wikidataUrl links the * topic to a real Wikidata entity. Returns null for a blank name (dropped * by the caller). * * @param mixed $topic * @return string|array{name:string,wikidataId?:string,wikidataUrl?:string}|null */ private static function sanitize_topic( $topic ) { if ( is_string( $topic ) ) { $name = sanitize_text_field( $topic ); return '' !== $name ? $name : null; } if ( ! is_array( $topic ) || empty( $topic['name'] ) ) { return null; } $out = array( 'name' => sanitize_text_field( $topic['name'] ) ); if ( ! empty( $topic['wikidataId'] ) ) { $out['wikidataId'] = sanitize_text_field( $topic['wikidataId'] ); } if ( ! empty( $topic['wikidataUrl'] ) ) { $out['wikidataUrl'] = esc_url_raw( $topic['wikidataUrl'] ); } return $out; } /** * Get sameAs URLs as an array. * * @return string[] */ public static function get_same_as() { return self::get_all()['same_as']; } /** * Get knowsAbout topics as an array. * * @return string[] */ public static function get_knows_about() { return self::get_all()['knows_about']; } /** * Calculate the Knowledge Graph Entity Score (0-100). * * Scoring breakdown: * Core identity (25 pts) — site type, org name, logo, URL * Contact info (10 pts) — phone, address * Entity links (25 pts) — sameAs URLs, 5 pts each, max 5 * Topical authority(20 pts) — knowsAbout topics, 5 pts each, max 4 * Formal identity (10 pts) — legalName, foundingDate * Trust signals (10 pts) — a named founder, a tax/VAT registry ID * * Contact Info was shrunk from 20 to 10 (phone 5 + address 5, was 10 each) * to make room for Trust Signals without exceeding the 0-100 scale this is * rendered on — kept in exact parity with the Node backend's * computeEntityScore() (backend/src/lib/entityScore.ts). * * @return array { score: int, breakdown: array, missing: string[] } */ public static function calculate_score() { $settings = get_option( 'schema_ai_settings', array() ); $entity = self::get_all(); $score = 0; $breakdown = array(); $missing = array(); // ── Core identity (25 pts) ───────────────────────────────── $core = 0; if ( ! empty( $settings['site_type'] ) ) { $core += 10; } else { $missing[] = __( 'Site type not configured', 'ai-schema-gen' ); } if ( ! empty( $settings['default_org_name'] ) ) { $core += 5; } else { $missing[] = __( 'Organization name missing', 'ai-schema-gen' ); } if ( ! empty( $settings['default_org_logo'] ) ) { $core += 5; } else { $missing[] = __( 'Organization logo missing', 'ai-schema-gen' ); } if ( ! empty( $settings['default_org_url'] ) ) { $core += 5; } else { $missing[] = __( 'Organization URL missing', 'ai-schema-gen' ); } $breakdown['core_identity'] = array( 'label' => __( 'Core Identity', 'ai-schema-gen' ), 'score' => $core, 'max' => 25 ); $score += $core; // ── Contact info (10 pts) ────────────────────────────────── $contact = 0; if ( ! empty( $settings['default_org_phone'] ) ) { $contact += 5; } else { $missing[] = __( 'Phone number missing', 'ai-schema-gen' ); } $has_address = ! empty( $settings['default_org_address'] ) || ! empty( $settings['default_org_city'] ) || ! empty( $settings['default_org_zip'] ); if ( $has_address ) { $contact += 5; } else { $missing[] = __( 'Business address missing', 'ai-schema-gen' ); } $breakdown['contact'] = array( 'label' => __( 'Contact Info', 'ai-schema-gen' ), 'score' => $contact, 'max' => 10 ); $score += $contact; // ── Entity links / sameAs (25 pts, 5 per URL, max 5) ────── $same_as_count = min( 5, count( $entity['same_as'] ) ); $links = $same_as_count * 5; $breakdown['entity_links'] = array( 'label' => __( 'Entity Links (sameAs)', 'ai-schema-gen' ), 'score' => $links, 'max' => 25 ); $score += $links; if ( $same_as_count === 0 ) { $missing[] = __( 'No sameAs directory links added', 'ai-schema-gen' ); } elseif ( $same_as_count < 3 ) { $missing[] = __( 'Add more sameAs links (3+ recommended)', 'ai-schema-gen' ); } // ── Topical authority / knowsAbout (20 pts, 5 per topic, max 4) ── $topic_count = min( 4, count( $entity['knows_about'] ) ); $topics = $topic_count * 5; $breakdown['topical_authority'] = array( 'label' => __( 'Topical Authority (knowsAbout)', 'ai-schema-gen' ), 'score' => $topics, 'max' => 20 ); $score += $topics; if ( $topic_count === 0 ) { $missing[] = __( 'No knowsAbout topics added', 'ai-schema-gen' ); } elseif ( $topic_count < 3 ) { $missing[] = __( 'Add more topics (3+ recommended)', 'ai-schema-gen' ); } // ── Formal identity (10 pts) ─────────────────────────────── $formal = 0; if ( ! empty( $entity['legal_name'] ) ) { $formal += 5; } else { $missing[] = __( 'Legal name missing', 'ai-schema-gen' ); } if ( ! empty( $entity['founding_date'] ) ) { $formal += 5; } else { $missing[] = __( 'Founding date missing', 'ai-schema-gen' ); } $breakdown['formal_identity'] = array( 'label' => __( 'Formal Identity', 'ai-schema-gen' ), 'score' => $formal, 'max' => 10 ); $score += $formal; // ── Trust signals (10 pts) ───────────────────────────────── $trust = 0; if ( ! empty( $entity['founders'] ) ) { $trust += 5; } else { $missing[] = __( 'No founders added', 'ai-schema-gen' ); } if ( ! empty( $entity['tax_id'] ) || ! empty( $entity['vat_id'] ) ) { $trust += 5; } else { $missing[] = __( 'No tax/VAT registry ID added', 'ai-schema-gen' ); } $breakdown['trust_signals'] = array( 'label' => __( 'Trust Signals (Founders, Registry IDs)', 'ai-schema-gen' ), 'score' => $trust, 'max' => 10 ); $score += $trust; return array( 'score' => $score, 'breakdown' => $breakdown, 'missing' => $missing, ); } // ------------------------------------------------------------------------- // Two-way sync helpers // ------------------------------------------------------------------------- /** * Get the ISO 8601 timestamp of the last local entity-profile change, * or an empty string if it has never been recorded on this install. * * @return string */ public static function get_updated_at() { return (string) get_option( self::CHANGE_OPTION, '' ); } /** * Extract just the synced entity fields from a settings blob, for comparing * "did the entity profile actually change" without being fooled by other * settings on the same option. * * @param array $settings The schema_ai_settings option. * @return array */ private static function sync_subset( $settings ) { $subset = array(); foreach ( self::$sync_fields as $opt_key ) { $subset[ $opt_key ] = isset( $settings[ $opt_key ] ) ? $settings[ $opt_key ] : ''; } return $subset; } /** * Hook: fires whenever schema_ai_settings is updated. Records a fresh * local-change timestamp only when one of the synced entity fields actually * changed, and only when the change originated locally (not applied from the * dashboard). Registered against both add_option and update_option. * * @param mixed $old_value Previous option value (or option name on add_option). * @param mixed $new_value New option value. */ public static function track_change( $old_value, $new_value ) { if ( self::$applying_remote ) { return; // These values came from the dashboard; don't restamp them. } // add_option_{$option} passes ( $option_name, $value ); normalise to old/new. if ( is_string( $old_value ) ) { $old_value = array(); } $old_value = is_array( $old_value ) ? $old_value : array(); $new_value = is_array( $new_value ) ? $new_value : array(); if ( self::sync_subset( $old_value ) === self::sync_subset( $new_value ) ) { return; // Entity fields unchanged — some other setting was saved. } update_option( self::CHANGE_OPTION, gmdate( 'c' ), false ); } /** * Apply an entity profile received from the dashboard/backend into the local * schema_ai_settings option, so the plugin's admin screen and generated * schema reflect edits made in the SaaS dashboard. * * @param array $canonical Entity fields keyed by canonical name * (same_as, knows_about, legal_name, …). * @param string $updated_at ISO 8601 timestamp the backend reported for it. */ public static function apply_remote( $canonical, $updated_at ) { if ( ! is_array( $canonical ) ) { return; } $settings = get_option( 'schema_ai_settings', array() ); if ( ! is_array( $settings ) ) { $settings = array(); } foreach ( self::$sync_fields as $canon => $opt_key ) { if ( ! array_key_exists( $canon, $canonical ) ) { continue; } $value = $canonical[ $canon ]; switch ( $canon ) { case 'same_as': $settings[ $opt_key ] = is_array( $value ) ? array_values( array_filter( array_map( 'esc_url_raw', $value ) ) ) : array(); break; case 'knows_about': $settings[ $opt_key ] = is_array( $value ) ? array_values( array_filter( array_map( array( __CLASS__, 'sanitize_topic' ), $value ) ) ) : array(); break; case 'description': $settings[ $opt_key ] = sanitize_textarea_field( (string) $value ); break; case 'founders': $settings[ $opt_key ] = is_array( $value ) ? array_values( array_filter( array_map( array( __CLASS__, 'sanitize_founder' ), $value ) ) ) : array(); break; default: $settings[ $opt_key ] = sanitize_text_field( (string) $value ); break; } } // Write settings without the change-tracker restamping them as local. self::$applying_remote = true; update_option( 'schema_ai_settings', $settings ); self::$applying_remote = false; // Adopt the backend's timestamp so neither side thinks it's newer. update_option( self::CHANGE_OPTION, sanitize_text_field( (string) $updated_at ), false ); } /** * Check whether a schema type is deprecated. * * @param string $type Schema.org type string. * @return bool */ public static function is_deprecated( $type ) { return in_array( $type, self::$deprecated_types, true ); } }