default_org_logo, a setting that already * existed for schema generation, unrelated to entity sync). Without a * forced re-stamp, that pre-existing value would never win the * last-write-wins race and would never reach the dashboard: its content * doesn't change when the plugin is upgraded, so track_change() never * fires, so CHANGE_OPTION stays stuck at whatever pre-upgrade timestamp * it already had, permanently older than the dashboard's. * * @var int */ const SYNC_FIELDS_VERSION = 2; /** * wp_options key storing which SYNC_FIELDS_VERSION this install has * already migrated to. * * @var string */ const SYNC_FIELDS_VERSION_OPTION = 'schema_ai_entity_sync_fields_version'; /** * Set true while we are writing settings that came *from* the dashboard, so * the change-tracking hook doesn't stamp them as a fresh local edit (which * would make the two sides fight over which is newer). * * @var bool */ private static $applying_remote = false; /** * The entity fields that make up the two-way-synced profile. * Maps the canonical key (used by the dashboard/API) to the plugin's * own schema_ai_settings key. * * site_type/sub_type use their own pre-existing (unprefixed) option keys * since they're also read directly by Schema_AI_Site_Type and * Schema_AI_Business_Subtype outside of this sync mechanism. * * @var array */ 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', // Logo/phone/address already existed as plugin settings under these // "default_org_*" keys (used directly by schema generation); they were // just never part of the two-way dashboard sync until now. 'org_logo' => 'default_org_logo', 'org_phone' => 'default_org_phone', 'org_address' => 'default_org_address', 'org_city' => 'default_org_city', 'org_zip' => 'default_org_zip', // New fields with no prior plugin-side equivalent. 'company_number' => 'entity_company_number', 'service_area' => 'entity_service_area', // "Does this local business have a public location?" — undefined/true // (the option key simply absent) means yes, matching the long-standing // default; explicitly '0' means a declared service-area business. 'has_physical_location' => 'entity_has_physical_location', ); /** * 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( array( __CLASS__, 'sanitize_same_as_url' ), $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'] ) : '', 'org_logo' => isset( $settings['default_org_logo'] ) ? self::sanitize_same_as_url( $settings['default_org_logo'] ) : '', 'org_phone' => isset( $settings['default_org_phone'] ) ? sanitize_text_field( $settings['default_org_phone'] ) : '', 'org_address' => isset( $settings['default_org_address'] ) ? sanitize_text_field( $settings['default_org_address'] ) : '', 'org_city' => isset( $settings['default_org_city'] ) ? sanitize_text_field( $settings['default_org_city'] ) : '', 'org_zip' => isset( $settings['default_org_zip'] ) ? sanitize_text_field( $settings['default_org_zip'] ) : '', 'company_number' => isset( $settings['entity_company_number'] ) ? sanitize_text_field( $settings['entity_company_number'] ) : '', 'service_area' => isset( $settings['entity_service_area'] ) && is_array( $settings['entity_service_area'] ) ? array_values( array_filter( array_map( array( __CLASS__, 'sanitize_service_area_entry' ), $settings['entity_service_area'] ) ) ) : array(), // Absent means "has a location" (the default) — only an explicitly // stored falsy value (from the settings page's radio choice, or a // dashboard edit via apply_remote()) means service-area-only. 'has_physical_location' => ! array_key_exists( 'entity_has_physical_location', $settings ) || (bool) $settings['entity_has_physical_location'], ); } /** * 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'] = self::sanitize_same_as_url( $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'] = self::sanitize_same_as_url( $topic['wikidataUrl'] ); } return $out; } /** * Sanitize a single service area entry. The dashboard stores these as * either a plain string or a location-picker shape ({name, placeId?, * lat?, lng?}); the plugin only ever needs the display name for the * generated areaServed/Place schema, so this always collapses to a * plain string. Returns null for a blank name (dropped by the caller). * * @param mixed $entry * @return string|null */ private static function sanitize_service_area_entry( $entry ) { if ( is_string( $entry ) ) { $name = sanitize_text_field( $entry ); return '' !== $name ? $name : null; } if ( ! is_array( $entry ) || empty( $entry['name'] ) ) { return null; } $name = sanitize_text_field( $entry['name'] ); return '' !== $name ? $name : null; } /** * Get sameAs URLs as an array. * * @return string[] */ public static function get_same_as() { return self::get_all()['same_as']; } /** * Trim before esc_url_raw() — WordPress's esc_url()/esc_url_raw() only * left-trims and otherwise converts any literal space to "%20" rather * than stripping it, so an untrimmed trailing space (e.g. from a value * synced in before this fix existed) gets baked into the URL forever * instead of being removed. Verified live 2026-08-07: a same_as URL * ending in "%20" on a customer's homepage schema, traced to exactly * this gap in the remote-sync write path (apply_remote() below never * trimmed, unlike the settings-page.php save handler which already did). * * @param string $url Raw URL, possibly with stray whitespace. * @return string Sanitized URL. */ public static function sanitize_same_as_url( $url ) { return esc_url_raw( trim( (string) $url ) ); } /** * 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' ); } // A declared service-area business (has_physical_location === false) // has correctly never filled in an address — earn the slot via a // declared service_area instead, so it isn't scored down (or nagged // about a "missing" address it deliberately doesn't have) for // something that doesn't apply to it. Matches the Node backend's // computeEntityScore() (backend/src/lib/entityScore.ts). if ( false === $entity['has_physical_location'] ) { if ( ! empty( $entity['service_area'] ) ) { $contact += 5; } else { $missing[] = __( 'Service area missing', 'ai-schema-gen' ); } } else { $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, '' ); } /** * One-time-per-version nudge: if this install hasn't yet migrated to the * current SYNC_FIELDS_VERSION, force a fresh local-change timestamp so * the newly-tracked fields (which may already hold real, pre-existing * local values from before they were part of the sync) actually win the * next last-write-wins race instead of silently never syncing. A site * with no local entity edit at all yet (fresh install, CHANGE_OPTION * still empty) has nothing to migrate and is skipped, since a real edit * will stamp it naturally the first time one happens. * * Called on every sync/pull attempt (cheap: one option read when * already migrated) so it self-heals on the very next scheduled sync * after an upgrade, with no action needed from the site owner. */ public static function maybe_migrate_sync_fields_version() { $stored_version = (int) get_option( self::SYNC_FIELDS_VERSION_OPTION, 1 ); if ( $stored_version >= self::SYNC_FIELDS_VERSION ) { return; } update_option( self::SYNC_FIELDS_VERSION_OPTION, self::SYNC_FIELDS_VERSION ); if ( '' !== self::get_updated_at() ) { update_option( self::CHANGE_OPTION, gmdate( 'c' ), false ); } } /** * 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( array( __CLASS__, 'sanitize_same_as_url' ), $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; case 'service_area': $settings[ $opt_key ] = is_array( $value ) ? array_values( array_filter( array_map( array( __CLASS__, 'sanitize_service_area_entry' ), $value ) ) ) : array(); break; case 'org_logo': $settings[ $opt_key ] = self::sanitize_same_as_url( (string) $value ); break; case 'has_physical_location': $settings[ $opt_key ] = (bool) $value; 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 ); } }