'', 'logo' => '', 'url' => '', 'phone' => '', 'address' => '', 'city' => '', 'state' => '', 'zip' => '', 'country' => '', ); // ── WordPress core ── $info['name'] = get_bloginfo( 'name' ); $info['url'] = home_url( '/' ); // Custom logo from the theme customizer. $custom_logo_id = get_theme_mod( 'custom_logo' ); if ( $custom_logo_id ) { $logo_src = wp_get_attachment_image_src( $custom_logo_id, 'full' ); if ( $logo_src ) { $info['logo'] = $logo_src[0]; } } // ── WooCommerce store settings ── if ( class_exists( 'WooCommerce' ) ) { $wc_address = get_option( 'woocommerce_store_address', '' ); $wc_address2 = get_option( 'woocommerce_store_address_2', '' ); $wc_city = get_option( 'woocommerce_store_city', '' ); $wc_postcode = get_option( 'woocommerce_store_postcode', '' ); $wc_country = get_option( 'woocommerce_default_country', '' ); // e.g. "US:NY" if ( ! empty( $wc_address ) && empty( $info['address'] ) ) { $info['address'] = trim( $wc_address . ( $wc_address2 ? ', ' . $wc_address2 : '' ) ); } if ( ! empty( $wc_city ) && empty( $info['city'] ) ) { $info['city'] = $wc_city; } if ( ! empty( $wc_postcode ) && empty( $info['zip'] ) ) { $info['zip'] = $wc_postcode; } if ( ! empty( $wc_country ) ) { $parts = explode( ':', $wc_country ); if ( empty( $info['country'] ) ) { $info['country'] = $parts[0]; } if ( isset( $parts[1] ) && empty( $info['state'] ) ) { $info['state'] = $parts[1]; } } } // ── Scan widgets, menus, and theme options for contact details ── $scraped = schema_ai_scrape_site_contact(); $scraped_fields = array( 'phone', 'address', 'city', 'state', 'zip', 'country' ); foreach ( $scraped_fields as $field ) { if ( ! empty( $scraped[ $field ] ) && empty( $info[ $field ] ) ) { $info[ $field ] = $scraped[ $field ]; } } return $info; } /** * Scan WordPress internals for contact details. * * Checks (in order): widgets, nav menus, Elementor data, theme mods, * wp_options, and finally the homepage HTML as a last resort. * * @return array { phone, address, city, state, zip, country } */ function schema_ai_scrape_site_contact() { $result = array( 'phone' => '', 'address' => '', 'city' => '', 'state' => '', 'zip' => '', 'country' => '', ); $phone_pattern = '/(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)\d{3}[-.\s]?\d{4}/'; // ── 1. Check nav menu items for tel: links ── if ( empty( $result['phone'] ) ) { $menu_locations = get_nav_menu_locations(); if ( is_array( $menu_locations ) ) { foreach ( $menu_locations as $location => $menu_id ) { if ( ! $menu_id ) { continue; } $menu_items = wp_get_nav_menu_items( $menu_id ); if ( ! is_array( $menu_items ) ) { continue; } foreach ( $menu_items as $item ) { if ( ! empty( $item->url ) && 0 === strpos( $item->url, 'tel:' ) ) { $result['phone'] = trim( str_replace( 'tel:', '', $item->url ) ); break 2; } } } } } // ── 2. Scan Elementor data (page builder widgets) ── if ( class_exists( '\Elementor\Plugin' ) || defined( 'ELEMENTOR_VERSION' ) ) { schema_ai_scan_elementor_data( $result, $phone_pattern ); } // ── 3. Scan theme mods (Customizer settings) ── schema_ai_scan_theme_mods( $result, $phone_pattern ); // ── 5. Scan wp_options for phone, address, city, state, zip, country ── global $wpdb; $option_searches = array( 'phone' => array( '%phone%', '%telephone%', '%tel_number%' ), 'address' => array( '%street_address%', '%address_line%', '%company_address%', '%business_address%' ), 'city' => array( '%_city%', '%business_city%', '%company_city%' ), 'state' => array( '%_state%', '%_region%', '%business_state%' ), 'zip' => array( '%postal%', '%zip%', '%postcode%' ), 'country' => array( '%_country%' ), ); foreach ( $option_searches as $field => $patterns ) { if ( ! empty( $result[ $field ] ) ) { continue; } $like_clauses = array(); $like_values = array(); foreach ( $patterns as $pattern ) { $like_clauses[] = 'option_name LIKE %s'; $like_values[] = $pattern; } $where = implode( ' OR ', $like_clauses ); $sql = 'SELECT option_value FROM ' . $wpdb->options . " WHERE ( " . $where . " ) AND option_value != '' AND option_name NOT LIKE %s LIMIT 10"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Required to scan wp_options for business data; $sql is built from hardcoded LIKE patterns only, all dynamic values passed via prepare() placeholders. $rows = $wpdb->get_results( $wpdb->prepare( $sql, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is built from hardcoded LIKE patterns; all dynamic values are passed as prepare() placeholders. array_merge( $like_values, array( '\_transient%' ) ) ) ); if ( $rows ) { foreach ( $rows as $row ) { $val = maybe_unserialize( $row->option_value ); if ( ! is_string( $val ) || strlen( $val ) > 200 ) { continue; } if ( 'phone' === $field ) { if ( preg_match( $phone_pattern, $val, $m ) ) { $digits = preg_replace( '/\D/', '', $m[0] ); if ( strlen( $digits ) >= 10 ) { $result['phone'] = trim( $m[0] ); break; } } } else { $val = trim( $val ); if ( ! empty( $val ) && strlen( $val ) > 1 ) { $result[ $field ] = $val; break; } } } } } // ── 6. Last resort: fetch the homepage HTML and parse it ── $still_missing = empty( $result['phone'] ) || empty( $result['address'] ); if ( $still_missing ) { schema_ai_scan_homepage_html( $result, $phone_pattern ); } return $result; } /** * Scan Elementor page builder data for contact details. * * Elementor stores widget data as JSON in _elementor_data postmeta. * This checks all Elementor templates and the homepage. * * @param array $result Reference to result array. * @param string $phone_pattern Phone regex pattern. */ function schema_ai_scan_elementor_data( &$result, $phone_pattern ) { global $wpdb; // Get Elementor data from templates (header/footer) and the homepage. // $wpdb->postmeta and $wpdb->posts are trusted WP core table references, not user input. $page_on_front = (int) get_option( 'page_on_front', 0 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT pm.meta_value FROM ' . $wpdb->postmeta . ' pm INNER JOIN ' . $wpdb->posts . ' p ON p.ID = pm.post_id WHERE pm.meta_key = \'_elementor_data\' AND pm.meta_value != \'\' AND p.post_status = \'publish\' AND ( p.post_type = \'elementor_library\' OR p.ID = %d ) LIMIT 20', $page_on_front ) ); if ( ! $rows ) { return; } foreach ( $rows as $row ) { $data = json_decode( $row->meta_value, true ); if ( ! is_array( $data ) ) { continue; } // Recursively extract all text content from Elementor widgets. $texts = array(); schema_ai_elementor_extract_text( $data, $texts ); $combined = implode( ' ', $texts ); if ( empty( $combined ) ) { continue; } // Phone from tel: links in raw HTML. if ( empty( $result['phone'] ) && preg_match( '/href=["\']tel:([^"\']+)["\']/i', $combined, $tel ) ) { $result['phone'] = trim( urldecode( $tel[1] ) ); } // Phone from text. if ( empty( $result['phone'] ) ) { $plain = wp_strip_all_tags( $combined ); if ( preg_match_all( $phone_pattern, $plain, $matches ) ) { foreach ( $matches[0] as $candidate ) { $digits = preg_replace( '/\D/', '', $candidate ); if ( strlen( $digits ) >= 10 ) { $result['phone'] = trim( $candidate ); break; } } } } // Address from text. if ( empty( $result['address'] ) ) { schema_ai_extract_address_from_text( wp_strip_all_tags( $combined ), $result ); } } } /** * Recursively extract text content from Elementor JSON data. * * @param array $elements Elementor elements array. * @param array $texts Reference to collected text strings. */ function schema_ai_elementor_extract_text( $elements, &$texts ) { foreach ( $elements as $element ) { if ( ! is_array( $element ) ) { continue; } // Extract text from widget settings. if ( ! empty( $element['settings'] ) && is_array( $element['settings'] ) ) { // All text-bearing keys in Elementor widgets. $text_keys = array( 'editor', 'title', 'html', 'text', 'description', 'address', 'phone', 'link', 'content', 'heading', 'icon_list', 'title_text', 'description_text', 'header_text', 'footer_text', 'tab_content', ); foreach ( $text_keys as $key ) { if ( ! empty( $element['settings'][ $key ] ) && is_string( $element['settings'][ $key ] ) ) { $texts[] = $element['settings'][ $key ]; } } // Check link URLs for tel: links. if ( ! empty( $element['settings']['link']['url'] ) && is_string( $element['settings']['link']['url'] ) ) { $texts[] = 'href="' . $element['settings']['link']['url'] . '"'; } // Google Maps widget stores address directly. if ( ! empty( $element['settings']['address'] ) && is_string( $element['settings']['address'] ) ) { $texts[] = $element['settings']['address']; } // Icon list items (often used for contact info in footers). if ( ! empty( $element['settings']['icon_list'] ) && is_array( $element['settings']['icon_list'] ) ) { foreach ( $element['settings']['icon_list'] as $icon_item ) { if ( ! empty( $icon_item['text'] ) && is_string( $icon_item['text'] ) ) { $texts[] = $icon_item['text']; } if ( ! empty( $icon_item['link']['url'] ) && is_string( $icon_item['link']['url'] ) ) { $texts[] = 'href="' . $icon_item['link']['url'] . '"'; } } } // Scan ALL string settings for tel: links and addresses (catch-all). foreach ( $element['settings'] as $skey => $sval ) { if ( is_string( $sval ) && strlen( $sval ) > 5 && strlen( $sval ) < 500 ) { if ( false !== strpos( $sval, 'tel:' ) || preg_match( '/\d{3}[-.\s]?\d{3}[-.\s]?\d{4}/', $sval ) ) { $texts[] = $sval; } } } } // Recurse into child elements. if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) { schema_ai_elementor_extract_text( $element['elements'], $texts ); } } } /** * Scan theme Customizer settings (theme_mods) for contact details. * * Many themes store phone, address, etc. in Customizer options. * * @param array $result Reference to result array. * @param string $phone_pattern Phone regex pattern. */ function schema_ai_scan_theme_mods( &$result, $phone_pattern ) { $mods = get_theme_mods(); if ( ! is_array( $mods ) ) { return; } $phone_keys = array( 'phone', 'telephone', 'tel', 'phone_number', 'contact_phone', 'header_phone', 'footer_phone', 'topbar_phone' ); $address_keys = array( 'address', 'street_address', 'company_address', 'business_address', 'footer_address' ); $city_keys = array( 'city', 'company_city', 'business_city' ); $state_keys = array( 'state', 'region', 'company_state' ); $zip_keys = array( 'zip', 'postal', 'postcode', 'zipcode', 'postal_code' ); $country_keys = array( 'country', 'company_country' ); foreach ( $mods as $key => $value ) { if ( ! is_string( $value ) || strlen( $value ) > 200 || empty( $value ) ) { continue; } $key_lower = strtolower( $key ); // Phone. if ( empty( $result['phone'] ) ) { foreach ( $phone_keys as $pk ) { if ( false !== strpos( $key_lower, $pk ) ) { if ( preg_match( $phone_pattern, $value, $m ) ) { $digits = preg_replace( '/\D/', '', $m[0] ); if ( strlen( $digits ) >= 10 ) { $result['phone'] = trim( $m[0] ); } } break; } } } // Address. if ( empty( $result['address'] ) ) { foreach ( $address_keys as $ak ) { if ( false !== strpos( $key_lower, $ak ) ) { $result['address'] = trim( $value ); break; } } } // City. if ( empty( $result['city'] ) ) { foreach ( $city_keys as $ck ) { if ( false !== strpos( $key_lower, $ck ) ) { $result['city'] = trim( $value ); break; } } } // State. if ( empty( $result['state'] ) ) { foreach ( $state_keys as $sk ) { if ( false !== strpos( $key_lower, $sk ) ) { $result['state'] = trim( $value ); break; } } } // Zip. if ( empty( $result['zip'] ) ) { foreach ( $zip_keys as $zk ) { if ( false !== strpos( $key_lower, $zk ) ) { $result['zip'] = trim( $value ); break; } } } // Country. if ( empty( $result['country'] ) ) { foreach ( $country_keys as $ck2 ) { if ( false !== strpos( $key_lower, $ck2 ) ) { $result['country'] = trim( $value ); break; } } } } // Also check footer text/copyright fields that may contain contact info. $footer_text_keys = array( 'footer_text', 'footer_content', 'copyright_text', 'footer_html', 'footer_info', 'topbar_text', 'header_text' ); foreach ( $footer_text_keys as $ftk ) { if ( ! empty( $mods[ $ftk ] ) && is_string( $mods[ $ftk ] ) ) { $text = $mods[ $ftk ]; if ( empty( $result['phone'] ) ) { if ( preg_match( '/href=["\']tel:([^"\']+)["\']/i', $text, $tel ) ) { $result['phone'] = trim( urldecode( $tel[1] ) ); } elseif ( preg_match( $phone_pattern, wp_strip_all_tags( $text ), $m ) ) { $digits = preg_replace( '/\D/', '', $m[0] ); if ( strlen( $digits ) >= 10 ) { $result['phone'] = trim( $m[0] ); } } } if ( empty( $result['address'] ) ) { schema_ai_extract_address_from_text( wp_strip_all_tags( $text ), $result ); } } } } /** * Fetch the homepage HTML and parse for contact details (last resort). * * @param array $result Reference to result array. * @param string $phone_pattern Phone regex pattern. */ function schema_ai_scan_homepage_html( &$result, $phone_pattern ) { $response = wp_remote_get( home_url( '/' ), array( 'timeout' => 5, 'sslverify' => false, ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return; } $html = wp_remote_retrieve_body( $response ); if ( empty( $html ) ) { return; } // Try to isolate the footer section first, then fall back to full HTML. $sections = array(); if ( preg_match( '/].*$/is', $html, $m ) ) { $sections[] = $m[0]; } if ( preg_match( '/class=["\'][^"\']*footer[^"\']*["\'].*$/is', $html, $m ) ) { $sections[] = $m[0]; } // Also check
tags (semantic HTML for contact info). if ( preg_match_all( '/]*>(.*?)<\/address>/is', $html, $m ) ) { foreach ( $m[1] as $addr_html ) { $sections[] = $addr_html; } } // Also check header/topbar area. if ( preg_match( '/].*?<\/header>/is', $html, $m ) ) { $sections[] = $m[0]; } // Fall back to full page. $sections[] = $html; foreach ( $sections as $section_html ) { $plain = wp_strip_all_tags( $section_html ); // Phone: check tel: links first. if ( empty( $result['phone'] ) && preg_match( '/href=["\']tel:([^"\']+)["\']/i', $section_html, $tel ) ) { $result['phone'] = trim( urldecode( $tel[1] ) ); } // Phone: fall back to text pattern. if ( empty( $result['phone'] ) && preg_match_all( $phone_pattern, $plain, $matches ) ) { foreach ( $matches[0] as $candidate ) { $digits = preg_replace( '/\D/', '', $candidate ); if ( strlen( $digits ) >= 10 ) { $result['phone'] = trim( $candidate ); break; } } } // Address from section text. if ( empty( $result['address'] ) ) { schema_ai_extract_address_from_text( $plain, $result ); } // If we found both phone and address, stop scanning. if ( ! empty( $result['phone'] ) && ! empty( $result['address'] ) ) { break; } } // If address is still missing, try Google Maps iframe src (often has the address). if ( empty( $result['address'] ) && preg_match( '/maps\.google\.com[^"\']*q=([^&"\']+)/i', $html, $maps ) ) { $map_addr = urldecode( $maps[1] ); $map_addr = str_replace( '+', ' ', $map_addr ); schema_ai_extract_address_from_text( $map_addr, $result ); // If extract didn't parse it, use the raw value as address. if ( empty( $result['address'] ) && strlen( $map_addr ) > 5 && strlen( $map_addr ) < 200 ) { $result['address'] = trim( $map_addr ); } } // Also try Google Maps embed with /place/ or @coordinates. if ( empty( $result['address'] ) && preg_match( '/google\.com\/maps[^"\']*\/place\/([^\/?"\']+)/i', $html, $maps ) ) { $map_addr = urldecode( $maps[1] ); $map_addr = str_replace( '+', ' ', $map_addr ); if ( strlen( $map_addr ) > 5 ) { schema_ai_extract_address_from_text( $map_addr, $result ); if ( empty( $result['address'] ) ) { $result['address'] = trim( $map_addr ); } } } } /** * Try to extract a US/CA style address from text. * * Looks for patterns like "123 Main St, Kingston, NY 12401". * * @param string $text Plain text to scan. * @param array $result Reference to result array to populate. */ function schema_ai_extract_address_from_text( $text, &$result ) { // Normalize whitespace (newlines, tabs, multiple spaces → single space). $text = preg_replace( '/\s+/', ' ', $text ); // Pattern 1 (strict): street number + known street suffix, city, state zip. // e.g. "123 Main Street, Kingston, NY 12401" $strict = '/(\d{1,6}\s+[A-Za-z0-9\s\.]+(?:St(?:reet)?|Ave(?:nue)?|Rd|Road|Blvd|Boulevard|Dr(?:ive)?|Ln|Lane|Way|Ct|Court|Pl(?:ace)?|Pkwy|Hwy|Highway|Circle|Cir|Terrace|Ter|Trail|Trl|Loop|Square|Sq|Path|Run|Crossing|Xing|Row|Point|Pt)[.,]?\s*)(?:[,\s]+([A-Za-z\s]+?)[,\s]+)?([A-Z]{2})\s+(\d{5}(?:-\d{4})?)/'; // Pattern 2 (broad): any number + words, city, state zip. // e.g. "123 Anything Here, City, NY 12401" $broad = '/(\d{1,6}\s+[A-Za-z0-9\s\.\-#]+?)[,\s]+([A-Za-z\s]+?)[,\s]+([A-Z]{2})\s+(\d{5}(?:-\d{4})?)/'; // Pattern 3 (zip-anchor): just find "City, ST 12345" even without street. $city_state_zip = '/([A-Z][a-z]+(?:\s[A-Z][a-z]+)*)[,\s]+([A-Z]{2})\s+(\d{5}(?:-\d{4})?)/'; $patterns = array( $strict, $broad ); foreach ( $patterns as $pattern ) { if ( preg_match( $pattern, $text, $m ) ) { if ( empty( $result['address'] ) && ! empty( $m[1] ) ) { $result['address'] = trim( $m[1], " ,.\t\n\r" ); } if ( empty( $result['city'] ) && ! empty( $m[2] ) ) { $result['city'] = trim( $m[2], " ,.\t\n\r" ); } if ( empty( $result['state'] ) && ! empty( $m[3] ) ) { $result['state'] = trim( $m[3] ); } if ( empty( $result['zip'] ) && ! empty( $m[4] ) ) { $result['zip'] = trim( $m[4] ); } if ( empty( $result['country'] ) ) { $result['country'] = 'US'; } return; // Found a full address, done. } } // Pattern 3: at least get city, state, zip if no full address found. if ( empty( $result['city'] ) && preg_match( $city_state_zip, $text, $m ) ) { $result['city'] = trim( $m[1] ); $result['state'] = trim( $m[2] ); $result['zip'] = trim( $m[3] ); if ( empty( $result['country'] ) ) { $result['country'] = 'US'; } } } /** * Get the rendered content of a widget by its ID. * * @param string $widget_id Widget ID (e.g. "text-2", "custom_html-3", "block-4"). * @return string Widget content or empty string. */ function schema_ai_get_widget_content( $widget_id ) { // Parse widget type and instance number from ID like "text-2". if ( ! preg_match( '/^(.+)-(\d+)$/', $widget_id, $parts ) ) { return ''; } $type = $parts[1]; $instance = (int) $parts[2]; // Map common widget types to their option keys. $option_map = array( 'text' => 'widget_text', 'custom_html' => 'widget_custom_html', 'block' => 'widget_block', ); $option_key = isset( $option_map[ $type ] ) ? $option_map[ $type ] : 'widget_' . $type; $all = get_option( $option_key, array() ); if ( ! isset( $all[ $instance ] ) || ! is_array( $all[ $instance ] ) ) { return ''; } $widget = $all[ $instance ]; // Text and Custom HTML widgets store content in 'content' or 'text'. if ( ! empty( $widget['content'] ) ) { return $widget['content']; } if ( ! empty( $widget['text'] ) ) { return $widget['text']; } return ''; } // ── Handle form submission ─────────────────────────────────────────── $message = ''; $message_type = ''; if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD'] && isset( $_POST['schema_ai_settings_nonce'] ) ) { if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['schema_ai_settings_nonce'] ) ), 'schema_ai_save_settings' ) ) { wp_die( esc_html__( 'Security check failed.', 'ai-schema-gen' ) ); } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to save these settings.', 'ai-schema-gen' ) ); } $raw = isset( $_POST['schema_ai_settings'] ) ? wp_unslash( $_POST['schema_ai_settings'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised individually below. // Start from the existing option rather than a blank array: entity_founders, // entity_tax_id, and entity_vat_id (dashboard-only editable, no field on this // page) would otherwise be silently wiped by every save of this form, since // they'd never be present in $raw to re-set below. $sanitized = get_option( 'schema_ai_settings', array() ); if ( ! is_array( $sanitized ) ) { $sanitized = array(); } // API Configuration. $sanitized['api_key'] = isset( $raw['api_key'] ) ? sanitize_text_field( $raw['api_key'] ) : ''; // Auto-Generate. $sanitized['auto_generate_publish'] = ! empty( $raw['auto_generate_publish'] ) ? true : false; // From this save on, the box above is what the plugin follows // (Schema_AI_Auto_Apply::is_enabled). The wizard's older switch is kept in // step so nothing reading it disagrees. $sanitized['auto_generate_explicit'] = true; $sanitized['enable_auto_apply'] = $sanitized['auto_generate_publish']; $sanitized['auto_generate_post_types'] = array(); if ( ! empty( $raw['auto_generate_post_types'] ) && is_array( $raw['auto_generate_post_types'] ) ) { $sanitized['auto_generate_post_types'] = array_map( 'sanitize_key', $raw['auto_generate_post_types'] ); } // Schema Defaults. $sanitized['default_org_name'] = isset( $raw['default_org_name'] ) ? sanitize_text_field( $raw['default_org_name'] ) : ''; $sanitized['default_org_logo'] = isset( $raw['default_org_logo'] ) ? esc_url_raw( $raw['default_org_logo'] ) : ''; $sanitized['default_org_url'] = isset( $raw['default_org_url'] ) ? esc_url_raw( $raw['default_org_url'] ) : ''; $sanitized['default_org_phone'] = isset( $raw['default_org_phone'] ) ? sanitize_text_field( $raw['default_org_phone'] ) : ''; $sanitized['default_org_email'] = isset( $raw['default_org_email'] ) ? sanitize_email( $raw['default_org_email'] ) : ''; $sanitized['default_org_address'] = isset( $raw['default_org_address'] ) ? sanitize_text_field( $raw['default_org_address'] ) : ''; $sanitized['default_org_city'] = isset( $raw['default_org_city'] ) ? sanitize_text_field( $raw['default_org_city'] ) : ''; $sanitized['default_org_state'] = isset( $raw['default_org_state'] ) ? sanitize_text_field( $raw['default_org_state'] ) : ''; $sanitized['default_org_zip'] = isset( $raw['default_org_zip'] ) ? sanitize_text_field( $raw['default_org_zip'] ) : ''; $sanitized['default_org_country'] = isset( $raw['default_org_country'] ) ? sanitize_text_field( $raw['default_org_country'] ) : ''; // Site type — validated against known slugs. $submitted_site_type = isset( $raw['site_type'] ) ? sanitize_key( $raw['site_type'] ) : ''; $sanitized['site_type'] = ( class_exists( 'Schema_AI_Site_Type' ) && Schema_AI_Site_Type::is_valid( $submitted_site_type ) ) ? $submitted_site_type : ''; // Business subtype — validated against known slugs. $submitted_subtype = isset( $raw['business_subtype'] ) ? sanitize_key( $raw['business_subtype'] ) : ''; $sanitized['business_subtype'] = ( class_exists( 'Schema_AI_Business_Subtype' ) && Schema_AI_Business_Subtype::is_valid( $submitted_subtype ) ) ? $submitted_subtype : ''; // Entity authority signals. $sanitized['entity_same_as'] = array(); if ( ! empty( $raw['entity_same_as'] ) && is_array( $raw['entity_same_as'] ) ) { foreach ( $raw['entity_same_as'] as $url ) { $trimmed = trim( (string) $url ); if ( '' === $trimmed ) { continue; } // Accept a plain domain (e.g. "linkedin.com/in/harry") same as a // fully qualified URL, defaulting to https:// when no scheme is // given, so users don't have to remember to type it. if ( ! preg_match( '#^[a-z][a-z0-9+.-]*://#i', $trimmed ) ) { $trimmed = 'https://' . $trimmed; } $clean = esc_url_raw( $trimmed ); if ( ! empty( $clean ) ) { $sanitized['entity_same_as'][] = $clean; } } } // knowsAbout topics: a plain string is the legacy shape; a topic linked to // a real Wikidata entity (settable from the dashboard, not this page) is // an array { name, wikidataId?, wikidataUrl? }. This page only ever // displays/edits the name via a single text input, so the Wikidata link // (if any) rides alongside it in parallel hidden-field arrays keyed by // the same row position, and is re-attached here rather than dropped. $sanitized['entity_knows_about'] = array(); if ( ! empty( $raw['entity_knows_about'] ) && is_array( $raw['entity_knows_about'] ) ) { $wikidata_ids = ! empty( $raw['entity_knows_about_wikidata_id'] ) && is_array( $raw['entity_knows_about_wikidata_id'] ) ? $raw['entity_knows_about_wikidata_id'] : array(); $wikidata_urls = ! empty( $raw['entity_knows_about_wikidata_url'] ) && is_array( $raw['entity_knows_about_wikidata_url'] ) ? $raw['entity_knows_about_wikidata_url'] : array(); foreach ( $raw['entity_knows_about'] as $i => $topic ) { $clean = sanitize_text_field( trim( (string) $topic ) ); if ( '' === $clean ) { continue; } $wikidata_id = isset( $wikidata_ids[ $i ] ) ? sanitize_text_field( trim( (string) $wikidata_ids[ $i ] ) ) : ''; $wikidata_url = isset( $wikidata_urls[ $i ] ) ? esc_url_raw( trim( (string) $wikidata_urls[ $i ] ) ) : ''; if ( '' !== $wikidata_id || '' !== $wikidata_url ) { $entry = array( 'name' => $clean ); if ( '' !== $wikidata_id ) { $entry['wikidataId'] = $wikidata_id; } if ( '' !== $wikidata_url ) { $entry['wikidataUrl'] = $wikidata_url; } $sanitized['entity_knows_about'][] = $entry; } else { $sanitized['entity_knows_about'][] = $clean; } } } $sanitized['entity_legal_name'] = isset( $raw['entity_legal_name'] ) ? sanitize_text_field( $raw['entity_legal_name'] ) : ''; $sanitized['entity_founding_date'] = isset( $raw['entity_founding_date'] ) ? sanitize_text_field( $raw['entity_founding_date'] ) : ''; $sanitized['entity_description'] = isset( $raw['entity_description'] ) ? sanitize_textarea_field( $raw['entity_description'] ) : ''; $sanitized['entity_num_employees'] = isset( $raw['entity_num_employees'] ) ? sanitize_text_field( $raw['entity_num_employees'] ) : ''; $sanitized['entity_company_number'] = isset( $raw['entity_company_number'] ) ? sanitize_text_field( $raw['entity_company_number'] ) : ''; // Radio group always submits "1" or "0" once rendered (one option is // always pre-selected) — absence only happens if the field was never // rendered at all (a form submitted before this feature existed), in // which case default to true, matching get_all()'s own default. $sanitized['entity_has_physical_location'] = isset( $raw['entity_has_physical_location'] ) ? ( '0' !== $raw['entity_has_physical_location'] ) : true; $sanitized['entity_service_area'] = array(); if ( ! empty( $raw['entity_service_area'] ) && is_array( $raw['entity_service_area'] ) ) { foreach ( $raw['entity_service_area'] as $area ) { $clean = sanitize_text_field( trim( (string) $area ) ); if ( '' !== $clean ) { $sanitized['entity_service_area'][] = $clean; } } } // Cache. $sanitized['enable_caching'] = ! empty( $raw['enable_caching'] ) ? true : false; $allowed_durations = array( 86400, 604800, 2592000 ); $sanitized['cache_duration'] = isset( $raw['cache_duration'] ) && in_array( (int) $raw['cache_duration'], $allowed_durations, true ) ? (int) $raw['cache_duration'] : 604800; // AI access (includes/class-ai-access.php). $sanitized['llms_txt_enabled'] = ! empty( $raw['llms_txt_enabled'] ); $sanitized['allow_ai_crawlers'] = ! empty( $raw['allow_ai_crawlers'] ); // Advanced. $sanitized['enable_analytics'] = ! empty( $raw['enable_analytics'] ) ? true : false; $sanitized['debug_mode'] = ! empty( $raw['debug_mode'] ) ? true : false; $allowed_log_levels = array( 'off', 'error', 'warning', 'info', 'debug' ); $sanitized['log_level'] = isset( $raw['log_level'] ) && in_array( $raw['log_level'], $allowed_log_levels, true ) ? $raw['log_level'] : 'error'; // Preserve daily free limit from existing settings. $existing = get_option( 'schema_ai_settings', array() ); $sanitized['monthly_free_limit'] = isset( $existing['monthly_free_limit'] ) ? (int) $existing['monthly_free_limit'] : 30; update_option( 'schema_ai_settings', $sanitized ); $message = __( 'Settings saved successfully.', 'ai-schema-gen' ); $message_type = 'success'; } // Handle cache clear request. if ( isset( $_POST['schema_ai_clear_cache'] ) && isset( $_POST['schema_ai_settings_nonce'] ) ) { if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['schema_ai_settings_nonce'] ) ), 'schema_ai_save_settings' ) && current_user_can( 'manage_options' ) ) { if ( class_exists( 'Schema_AI_Schema_Cache' ) ) { $cache = new Schema_AI_Schema_Cache(); $cleared = $cache->invalidate_all(); $message = sprintf( /* translators: %d: number of cache entries cleared */ __( 'Cache cleared. %d entries removed.', 'ai-schema-gen' ), $cleared ); $message_type = 'success'; } } } // ── Load current settings ──────────────────────────────────────────── $settings = get_option( 'schema_ai_settings', array() ); $api_key = isset( $settings['api_key'] ) ? $settings['api_key'] : ''; $ai_provider = isset( $settings['ai_provider'] ) ? $settings['ai_provider'] : 'openai'; $ai_model_free = isset( $settings['ai_model_free'] ) ? $settings['ai_model_free'] : 'gpt-4o-mini'; $ai_model_premium = isset( $settings['ai_model_premium'] ) ? $settings['ai_model_premium'] : 'gpt-4o-mini'; // Shown as the plugin will actually behave, including a site switched on by // the setup wizard before this box was wired up. $auto_generate_publish = Schema_AI_Auto_Apply::is_enabled( is_array( $settings ) ? $settings : array() ); $auto_post_types = Schema_AI_Auto_Apply::get_post_types( is_array( $settings ) ? $settings : array() ); // ── Auto-detect business info from other plugins / WordPress settings ── // These are used as fallbacks only when our own settings are empty. $auto_detected = schema_ai_detect_business_info(); $default_org_name = ! empty( $settings['default_org_name'] ) ? $settings['default_org_name'] : $auto_detected['name']; $default_org_logo = ! empty( $settings['default_org_logo'] ) ? $settings['default_org_logo'] : $auto_detected['logo']; $default_org_url = ! empty( $settings['default_org_url'] ) ? $settings['default_org_url'] : $auto_detected['url']; $default_org_phone = ! empty( $settings['default_org_phone'] ) ? $settings['default_org_phone'] : $auto_detected['phone']; // No auto-detect fallback: guessing a public contact address by scraping the // site risks picking up a personal or webmaster address. Left for the owner // to enter deliberately. $default_org_email = ! empty( $settings['default_org_email'] ) ? $settings['default_org_email'] : ''; $default_org_address = ! empty( $settings['default_org_address'] ) ? $settings['default_org_address'] : $auto_detected['address']; $default_org_city = ! empty( $settings['default_org_city'] ) ? $settings['default_org_city'] : $auto_detected['city']; $default_org_state = ! empty( $settings['default_org_state'] ) ? $settings['default_org_state'] : $auto_detected['state']; $default_org_zip = ! empty( $settings['default_org_zip'] ) ? $settings['default_org_zip'] : $auto_detected['zip']; $default_org_country = ! empty( $settings['default_org_country'] ) ? $settings['default_org_country'] : $auto_detected['country']; $site_type = isset( $settings['site_type'] ) ? $settings['site_type'] : ''; $business_subtype = isset( $settings['business_subtype'] ) ? $settings['business_subtype'] : ''; $entity_has_physical_location = ! array_key_exists( 'entity_has_physical_location', $settings ) || (bool) $settings['entity_has_physical_location']; $is_local_site_type = $site_type && class_exists( 'Schema_AI_Site_Type' ) && in_array( $site_type, Schema_AI_Site_Type::get_local_business_slugs(), true ); $enable_caching = isset( $settings['enable_caching'] ) ? (bool) $settings['enable_caching'] : true; $cache_duration = isset( $settings['cache_duration'] ) ? (int) $settings['cache_duration'] : 604800; $enable_analytics = ! empty( $settings['enable_analytics'] ); $debug_mode = ! empty( $settings['debug_mode'] ); $llms_txt_enabled = ! array_key_exists( 'llms_txt_enabled', (array) $settings ) || ! empty( $settings['llms_txt_enabled'] ); $allow_ai_crawlers = ! empty( $settings['allow_ai_crawlers'] ); $log_level = isset( $settings['log_level'] ) ? $settings['log_level'] : 'error'; $entity_legal_name = isset( $settings['entity_legal_name'] ) ? $settings['entity_legal_name'] : ''; $entity_founding_date = isset( $settings['entity_founding_date'] ) ? $settings['entity_founding_date'] : ''; $entity_description = isset( $settings['entity_description'] ) ? $settings['entity_description'] : ''; $entity_num_employees = isset( $settings['entity_num_employees'] ) ? $settings['entity_num_employees'] : ''; $entity_company_number = isset( $settings['entity_company_number'] ) ? $settings['entity_company_number'] : ''; // Get all public post types for the auto-generate checkboxes. $post_types = get_post_types( array( 'public' => true ), 'objects' ); ?>

> > >