plugin_name = $plugin_name; $this->version = $version; // Redirect to setup wizard on first activation. add_action( 'admin_init', array( $this, 'maybe_redirect_to_setup' ) ); // Suppress third-party admin notices on our plugin pages. add_action( 'in_admin_header', array( $this, 'suppress_third_party_notices' ), 1 ); // AJAX handler for verifying API key (server-side to avoid CORS). add_action( 'wp_ajax_schema_ai_verify_key', array( $this, 'ajax_verify_key' ) ); // AJAX handler for dismissing the local-business info banner. add_action( 'wp_ajax_schema_ai_dismiss_business_banner', array( $this, 'ajax_dismiss_business_banner' ) ); // AJAX handler for manually triggering a dashboard sync. add_action( 'wp_ajax_schema_ai_force_sync', array( $this, 'ajax_force_sync' ) ); // "Connect Your AI Schema Gen Account" popup flow: init starts the // session, the callback lands here once the dashboard approves it. add_action( 'wp_ajax_schema_ai_connect_init', array( $this, 'ajax_connect_init' ) ); add_action( 'admin_post_schema_ai_connect_callback', array( $this, 'connect_callback' ) ); add_action( 'wp_ajax_schema_ai_connect_status', array( $this, 'ajax_connect_status' ) ); // Show admin notice if DB tables are still missing after auto-create. add_action( 'admin_notices', array( $this, 'maybe_show_db_notice' ) ); // Native WP Dashboard widget (the "At a Glance" screen every admin // already sees), same real estate Rank Math/Yoast use for their own // overview widgets. add_action( 'wp_dashboard_setup', array( $this, 'register_dashboard_widget' ) ); } /** * Show an admin notice to admins if the primary DB table is missing. * * Fires AFTER schema_ai_run() has already attempted maybe_create_tables(), * so if the table is still absent it means create_tables() itself failed * (e.g. insufficient MySQL privileges). The notice tells the admin what * happened without requiring access to debug.log. */ public function maybe_show_db_notice() { if ( ! current_user_can( 'manage_options' ) ) { return; } global $wpdb; $table = $wpdb->prefix . 'schema_ai_schemas'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- SHOW TABLES is safe; admin-only; not on user data. $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; if ( $exists ) { return; } $last_error = $wpdb->last_error ? esc_html( $wpdb->last_error ) : __( 'Unknown error — check wp-content/debug.log', 'ai-schema-gen' ); echo '

'; echo '' . esc_html__( 'AI Schema Gen — Database Error', 'ai-schema-gen' ) . '
'; /* translators: %s: MySQL error message */ printf( esc_html__( 'The plugin\'s database tables could not be created. MySQL said: %s', 'ai-schema-gen' ), '' . $last_error . '' ); echo '
' . esc_html__( 'Please deactivate and reactivate the plugin, or contact your host to confirm your database user has CREATE TABLE privileges.', 'ai-schema-gen' ); echo '

'; } /** * AJAX handler: verify an API key by calling the SaaS backend server-side. */ public function ajax_verify_key() { check_ajax_referer( 'schema_ai_verify_key' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'ai-schema-gen' ) ) ); } $api_key = isset( $_POST['api_key'] ) ? sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) : ''; if ( empty( $api_key ) ) { wp_send_json_error( array( 'message' => __( 'Please enter an API key.', 'ai-schema-gen' ) ) ); } $response = wp_remote_get( SCHEMA_AI_API_BASE . '/auth/me', array( 'headers' => array( 'X-API-Key' => $api_key ), 'timeout' => 15, ) ); if ( is_wp_error( $response ) ) { wp_send_json_error( array( 'message' => __( 'Could not connect to AI Schema Gen server.', 'ai-schema-gen' ) ) ); } $code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 === $code && ! empty( $body['success'] ) && ! empty( $body['data']['user'] ) ) { $plan = isset( $body['data']['user']['plan'] ) ? $body['data']['user']['plan'] : 'free'; update_user_meta( get_current_user_id(), 'schema_ai_plan', $plan ); wp_send_json_success( array( 'plan' => $plan ) ); } else { $msg = isset( $body['error'] ) ? $body['error'] : __( 'Invalid API key.', 'ai-schema-gen' ); wp_send_json_error( array( 'message' => $msg ) ); } } /** * AJAX handler: dismiss the local-business info banner permanently. */ public function ajax_dismiss_business_banner() { check_ajax_referer( 'schema_ai_dismiss_business_banner' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error(); } $settings = get_option( 'schema_ai_settings', array() ); $settings['not_local_business'] = true; update_option( 'schema_ai_settings', $settings ); wp_send_json_success(); } /** * AJAX handler: manually trigger a full two-way sync with the SaaS dashboard. * * run_sync() pushes this site's pages + entity profile up, and applies the * reconciled entity profile the backend returns — so a dashboard edit that's * newer than the local copy is pulled straight back down on the same click, * instead of waiting for the 6-hour background sync. */ public function ajax_force_sync() { check_ajax_referer( 'schema_ai_force_sync' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'ai-schema-gen' ) ) ); } if ( ! class_exists( 'Schema_AI_Sync' ) ) { wp_send_json_error( array( 'message' => __( 'Sync class not available.', 'ai-schema-gen' ) ) ); } // Clear the admin-load pull throttle so this manual sync always reflects // the very latest dashboard state rather than a recently-throttled pull. delete_transient( Schema_AI_Sync::PULL_THROTTLE ); $sync = new Schema_AI_Sync(); $result = $sync->run_sync(); if ( $result ) { wp_send_json_success( array( 'message' => __( 'Synced both ways: pushed your latest changes and pulled the latest from your dashboard.', 'ai-schema-gen' ) ) ); } else { wp_send_json_error( array( 'message' => __( 'Sync failed. Please check your API key and try again.', 'ai-schema-gen' ) ) ); } } /** * AJAX handler: start a "Connect Your AI Schema Gen Account" session. * * Registers this site with the SaaS backend (no API key needed yet, that's * the entire point of this flow) and hands back a popup URL carrying the * opaque `state` token the backend issued. The state is also stashed in a * transient so connect_callback() can later confirm the callback actually * corresponds to a session this admin, in this browser, just started. */ public function ajax_connect_init() { check_ajax_referer( 'schema_ai_connect' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'ai-schema-gen' ) ) ); } $response = wp_remote_post( SCHEMA_AI_API_BASE . '/connect/init', array( 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'siteUrl' => home_url( '/' ) ) ), 'timeout' => 15, ) ); if ( is_wp_error( $response ) ) { wp_send_json_error( array( 'message' => __( 'Could not reach the AI Schema Gen server. Please try again.', 'ai-schema-gen' ) ) ); } $code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 201 !== $code || empty( $body['data']['state'] ) ) { $msg = isset( $body['error'] ) ? $body['error'] : __( 'Could not start the connection. Please try again.', 'ai-schema-gen' ); wp_send_json_error( array( 'message' => $msg ) ); } $state = sanitize_text_field( $body['data']['state'] ); set_transient( 'schema_ai_connect_state_' . get_current_user_id(), $state, 10 * MINUTE_IN_SECONDS ); wp_send_json_success( array( 'popupUrl' => SCHEMA_AI_DASHBOARD_URL . '/connect-plugin?state=' . rawurlencode( $state ), 'state' => $state, ) ); } /** * Lightweight poll target for the setup wizard's connect script. * * The wizard used to watch `popup.closed` and reload when the popup * went away. That silently breaks when a popup blocker neuters the * window handle, when the browser refuses the popup's own * `window.close()`, or when a cross-origin hop severs the opener link, * leaving the wizard stuck on Step 1 even though the connect succeeded. * Now the wizard also just asks us directly: connect_callback() drops a * short-lived marker keyed to this admin, and this returns whether it's * set for the state the wizard is waiting on. */ public function ajax_connect_status() { check_ajax_referer( 'schema_ai_connect' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'ai-schema-gen' ) ) ); } $state = isset( $_POST['state'] ) ? sanitize_text_field( wp_unslash( $_POST['state'] ) ) : ''; $done_key = 'schema_ai_connect_done_' . get_current_user_id(); $done = get_transient( $done_key ); $connected = '' !== $state && false !== $done && hash_equals( (string) $done, $state ); if ( $connected ) { delete_transient( $done_key ); } wp_send_json_success( array( 'connected' => $connected ) ); } /** * Callback the connect popup's final redirect lands on, server-to-server * exchanges the one-time code for a real API key, and saves it. * * Reachable at admin-post.php?action=schema_ai_connect_callback. The * `state`+`code` pair together is the CSRF protection here (mirroring a * standard OAuth authorization-code exchange): `state` must match what * this specific admin's ajax_connect_init() call stashed in a transient * moments ago, and `code` is a single-use secret the backend only ever * handed to the authenticated dashboard user who approved the request, a * forged or replayed request can't produce a matching pair. */ public function connect_callback() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Permission denied.', 'ai-schema-gen' ) ); } $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; $transient_key = 'schema_ai_connect_state_' . get_current_user_id(); $expected_state = get_transient( $transient_key ); if ( empty( $state ) || empty( $code ) || empty( $expected_state ) || ! hash_equals( $expected_state, $state ) ) { $this->render_connect_result( false, __( 'This connection link is invalid or has expired. Please close this window and try connecting again.', 'ai-schema-gen' ) ); return; } delete_transient( $transient_key ); $response = wp_remote_post( SCHEMA_AI_API_BASE . '/connect/exchange', array( 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'state' => $state, 'code' => $code ) ), 'timeout' => 15, ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { $this->render_connect_result( false, __( 'Could not complete the connection. Please close this window and try again.', 'ai-schema-gen' ) ); return; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); $api_key = isset( $body['data']['apiKey'] ) ? sanitize_text_field( $body['data']['apiKey'] ) : ''; if ( empty( $api_key ) ) { $this->render_connect_result( false, __( 'Could not complete the connection. Please close this window and try again.', 'ai-schema-gen' ) ); return; } // Saving here fires update_option_schema_ai_settings, the same hook // Schema_AI_Restore::maybe_restore() listens on, so any existing // dashboard data for this site is restored automatically. $settings = get_option( 'schema_ai_settings', array() ); $settings['api_key'] = $api_key; update_option( 'schema_ai_settings', $settings ); // Marker the setup wizard's connect script polls for (see // ajax_connect_status()), so it can advance past Step 1 the moment // the exchange completes rather than waiting on the popup to close. set_transient( 'schema_ai_connect_done_' . get_current_user_id(), $state, 5 * MINUTE_IN_SECONDS ); $this->render_connect_result( true, __( 'Your site is connected to AI Schema Gen.', 'ai-schema-gen' ) ); } /** * Tiny standalone HTML page shown inside the connect popup. The opening * window polls popup.closed and reloads itself once this closes, so this * page's only job is to tell the admin what happened, then close. * * @param bool $success Whether the connection succeeded. * @param string $message Message to display. */ private function render_connect_result( $success, $message ) { ?> <?php esc_html_e( 'AI Schema Gen', 'ai-schema-gen' ); ?>

#adminmenu #toplevel_page_ai-schema-gen .wp-menu-image{opacity:1 !important;filter:none !important;}'; } /** * Register the admin menu and submenu pages. * * Creates a top-level "Schema AI" menu with Dashboard, All Schemas, * and Settings submenus. */ public function add_admin_menu() { // Top-level menu. add_menu_page( __( 'AI Schema Gen', 'ai-schema-gen' ), __( 'AI Schema Gen', 'ai-schema-gen' ), 'edit_posts', 'ai-schema-gen', array( $this, 'display_dashboard_page' ), $this->get_menu_icon(), 30 ); // Dashboard submenu (replaces the default duplicate). add_submenu_page( 'ai-schema-gen', __( 'Dashboard', 'ai-schema-gen' ), __( 'Dashboard', 'ai-schema-gen' ), 'edit_posts', 'ai-schema-gen', array( $this, 'display_dashboard_page' ) ); // Schema Tester submenu. add_submenu_page( 'ai-schema-gen', __( 'Schema Tester', 'ai-schema-gen' ), __( 'Schema Tester', 'ai-schema-gen' ), 'edit_posts', 'ai-schema-gen-tester', array( $this, 'display_tester_page' ) ); // Settings submenu (requires manage_options). add_submenu_page( 'ai-schema-gen', __( 'Settings', 'ai-schema-gen' ), __( 'Settings', 'ai-schema-gen' ), 'manage_options', 'ai-schema-gen-settings', array( $this, 'display_settings_page' ) ); // Upgrade submenu. add_submenu_page( 'ai-schema-gen', __( 'Upgrade', 'ai-schema-gen' ), __( 'Upgrade', 'ai-schema-gen' ), 'edit_posts', 'ai-schema-gen-upgrade', array( $this, 'display_upgrade_page' ) ); // Setup wizard (hidden from menu). add_submenu_page( null, __( 'AI Schema Gen Setup', 'ai-schema-gen' ), __( 'Setup', 'ai-schema-gen' ), 'manage_options', 'ai-schema-gen-setup', array( $this, 'display_setup_page' ) ); } /** * Register the stylesheets for the admin area. * * Loads admin CSS only on Schema AI pages and post edit screens * where the meta box is displayed. * * @param string $hook The current admin page hook suffix. */ public function enqueue_styles( $hook ) { // Load on Schema AI admin pages. $is_schema_ai_page = ( false !== strpos( $hook, 'ai-schema-gen' ) ); // Load on post edit screens for the meta box. $is_edit_screen = in_array( $hook, array( 'post.php', 'post-new.php' ), true ); if ( ! $is_schema_ai_page && ! $is_edit_screen ) { return; } wp_enqueue_style( 'ai-schema-gen-admin', plugin_dir_url( __FILE__ ) . 'assets/css/admin.css', array(), $this->version ); // Entity Authority repeater styles (settings page only). if ( false !== strpos( $hook, 'ai-schema-gen-settings' ) ) { wp_add_inline_style( 'ai-schema-gen-admin', '.ai-schema-gen-repeater-row{display:flex;gap:8px;margin-bottom:8px;align-items:center;}' . '.ai-schema-gen-repeater-row .regular-text{flex:1;margin:0;}' . '.ai-schema-gen-remove-row{flex-shrink:0;line-height:1;padding:0 8px;font-size:16px;}' ); } // Setup wizard styles. if ( false !== strpos( $hook, 'ai-schema-gen-setup' ) ) { wp_enqueue_style( 'ai-schema-gen-setup-wizard', plugin_dir_url( __FILE__ ) . 'assets/css/setup-wizard.css', array(), $this->version ); } } /** * Register the JavaScript for the admin area. * * Loads admin JS only on Schema AI pages and post edit screens. * Localizes script with REST API data, user plan, and settings. * * @param string $hook The current admin page hook suffix. */ public function enqueue_scripts( $hook ) { // Load on Schema AI admin pages. $is_schema_ai_page = ( false !== strpos( $hook, 'ai-schema-gen' ) ); // Load on post edit screens for the meta box. $is_edit_screen = in_array( $hook, array( 'post.php', 'post-new.php' ), true ); if ( ! $is_schema_ai_page && ! $is_edit_screen ) { return; } // Load the WordPress media library on the settings page (for logo upload). if ( false !== strpos( $hook, 'ai-schema-gen-settings' ) ) { wp_enqueue_media(); } wp_enqueue_script( 'ai-schema-gen-admin', plugin_dir_url( __FILE__ ) . 'assets/js/build/index.js', array( 'wp-element', 'wp-components', 'wp-i18n', 'wp-api-fetch' ), $this->version, true ); $current_user_id = get_current_user_id(); $plan = get_user_meta( $current_user_id, 'schema_ai_plan', true ); // Get site logo for defaults. $site_logo = ''; $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 ) { $site_logo = $logo_src[0]; } } // Settings page script. if ( false !== strpos( $hook, 'ai-schema-gen-settings' ) ) { wp_enqueue_script( 'ai-schema-gen-settings-page', plugin_dir_url( __FILE__ ) . 'assets/js/settings-page.js', array( 'jquery' ), $this->version, true ); wp_localize_script( 'ai-schema-gen-settings-page', 'schemaAiSettings', array( 'ajaxUrl' => esc_url( admin_url( 'admin-ajax.php' ) ), 'nonce' => wp_create_nonce( 'schema_ai_verify_key' ), 'i18n' => array( 'enterApiKey' => __( 'Please enter an API key first.', 'ai-schema-gen' ), 'verifying' => __( 'Verifying...', 'ai-schema-gen' ), 'connected' => __( 'Connected successfully!', 'ai-schema-gen' ), 'connectionFailed' => __( 'Connection failed.', 'ai-schema-gen' ), 'connectionFailedRetry' => __( 'Connection failed. Please try again.', 'ai-schema-gen' ), 'selectLogo' => __( 'Select Organization Logo', 'ai-schema-gen' ), 'useLogo' => __( 'Use as Logo', 'ai-schema-gen' ), ), ) ); // Site type dropdown + entity authority repeater scripts. $settings_raw = get_option( 'schema_ai_settings', array() ); wp_enqueue_script( 'ai-schema-gen-settings-site-type', plugin_dir_url( __FILE__ ) . 'assets/js/settings-site-type.js', array(), $this->version, true ); wp_localize_script( 'ai-schema-gen-settings-site-type', 'schemaAISiteTypeData', array( // Which site type categories should show the subtype row at all: // presence of a non-empty subtype list, not the old "is local // business" flag, which incorrectly hid subtypes for // government/news_media/nonprofit/organizations_groups. 'subtypeCategorySlugs' => class_exists( 'Schema_AI_Business_Subtype' ) ? Schema_AI_Business_Subtype::get_categories_with_subtypes() : array(), 'currentSiteType' => isset( $settings_raw['site_type'] ) ? $settings_raw['site_type'] : '', // Which site types are "local" businesses at all — drives whether // the "does this business have a public location?" question shows. 'localSlugs' => class_exists( 'Schema_AI_Site_Type' ) ? Schema_AI_Site_Type::get_local_business_slugs() : array(), ) ); wp_enqueue_script( 'ai-schema-gen-settings-repeater', plugin_dir_url( __FILE__ ) . 'assets/js/settings-repeater.js', array(), $this->version, true ); } // Setup wizard script. if ( false !== strpos( $hook, 'ai-schema-gen-setup' ) ) { wp_enqueue_script( 'ai-schema-gen-setup-wizard', plugin_dir_url( __FILE__ ) . 'assets/js/setup-wizard.js', array( 'jquery' ), $this->version, true ); wp_localize_script( 'ai-schema-gen-setup-wizard', 'schemaAiWizard', array( 'nonce' => wp_create_nonce( 'schema_ai_verify_key' ), 'i18n' => array( 'enterApiKey' => __( 'Please enter an API key.', 'ai-schema-gen' ), 'verifying' => __( 'Verifying...', 'ai-schema-gen' ), 'connected' => __( 'Connected!', 'ai-schema-gen' ), 'plan' => __( 'Plan:', 'ai-schema-gen' ), 'invalidKey' => __( 'Invalid API key. Please check and try again.', 'ai-schema-gen' ), 'couldNotVerify' => __( 'Could not verify. Check your API key or try again later.', 'ai-schema-gen' ), 'verifyKey' => __( 'Verify Key', 'ai-schema-gen' ), ), ) ); } // Setup wizard: "Connect Your AI Schema Gen Account" popup flow. if ( false !== strpos( $hook, 'ai-schema-gen-setup' ) ) { wp_enqueue_script( 'ai-schema-gen-connect-account', plugin_dir_url( __FILE__ ) . 'assets/js/connect-account.js', array( 'jquery' ), $this->version, true ); wp_localize_script( 'ai-schema-gen-connect-account', 'schemaAiConnect', array( 'ajaxUrl' => esc_url( admin_url( 'admin-ajax.php' ) ), 'nonce' => wp_create_nonce( 'schema_ai_connect' ), // Where to send the wizard once the connect finishes. The // `connected=1` param doubles as a cache-buster so a stale // Step 1 page can't be served back. 'setupUrl' => esc_url_raw( admin_url( 'admin.php?page=ai-schema-gen-setup&connected=1' ) ), 'i18n' => array( 'connecting' => __( 'Connecting...', 'ai-schema-gen' ), 'connectButton' => __( 'Connect Your AI Schema Gen Account', 'ai-schema-gen' ), 'popupBlocked' => __( 'Your browser blocked the popup. Please allow popups for this site and try again.', 'ai-schema-gen' ), 'failed' => __( 'Could not start the connection. Please try again.', 'ai-schema-gen' ), ), ) ); } // Setup wizard: business subtype dropdown. if ( false !== strpos( $hook, 'ai-schema-gen-setup' ) ) { wp_enqueue_script( 'ai-schema-gen-setup-wizard-subtype', plugin_dir_url( __FILE__ ) . 'assets/js/setup-wizard-subtype.js', array(), $this->version, true ); wp_localize_script( 'ai-schema-gen-setup-wizard-subtype', 'schemaAIWizardData', array( 'subtypeCategorySlugs' => class_exists( 'Schema_AI_Business_Subtype' ) ? Schema_AI_Business_Subtype::get_categories_with_subtypes() : array(), 'localSlugs' => class_exists( 'Schema_AI_Site_Type' ) ? Schema_AI_Site_Type::get_local_business_slugs() : array(), ) ); } $entity_score = class_exists( 'Schema_AI_Entity_Settings' ) ? Schema_AI_Entity_Settings::calculate_score() : array( 'score' => 0, 'breakdown' => array(), 'missing' => array() ); // Never expose the API key (or any secret) to the browser. This script is // enqueued on post.php/post-new.php too, where Author/Contributor-level // users can read page source — they must not see the site's API key. $public_settings = get_option( 'schema_ai_settings', array() ); $has_api_key = ! empty( $public_settings['api_key'] ); unset( $public_settings['api_key'] ); wp_localize_script( 'ai-schema-gen-admin', 'schemaAiData', array( 'restUrl' => esc_url( rest_url( 'ai-schema-gen/v1/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'postId' => get_the_ID(), 'postUrl' => get_the_ID() ? esc_url( get_permalink( get_the_ID() ) ) : '', 'adminUrl' => esc_url( admin_url() ), 'ajaxUrl' => esc_url( admin_url( 'admin-ajax.php' ) ), 'syncNonce' => wp_create_nonce( 'schema_ai_force_sync' ), 'upgradeUrl' => esc_url( admin_url( 'admin.php?page=ai-schema-gen-upgrade' ) ), 'pluginUrl' => esc_url( SCHEMA_AI_PLUGIN_URL ), 'version' => $this->version, 'plan' => ! empty( $plan ) ? $plan : 'free', 'settings' => $public_settings, 'hasApiKey' => $has_api_key, 'dismissBannerNonce' => wp_create_nonce( 'schema_ai_dismiss_business_banner' ), 'entityScore' => $entity_score, 'readiness' => $this->get_readiness_snapshot(), // Only the dashboard's empty state uses this, and only when // connected — skip the query everywhere else. 'starterPages' => ( 'toplevel_page_ai-schema-gen' === $hook && $has_api_key ) ? $this->get_starter_pages() : array(), 'wpDefaults' => array( 'siteName' => get_bloginfo( 'name' ), 'siteUrl' => home_url( '/' ), 'siteLogo' => $site_logo, ), 'plans' => $this->get_plans_data(), ) ); } /** * A short list of the site's own pages to offer as one-click "generate * your first schema" targets on the dashboard's empty state. Static * front page first (highest schema value), then the most recently * modified published Pages; falls back to recent Posts on a site with * no Pages. Only IDs/titles/URLs reach the browser — the generate REST * route re-checks capability per request. * * @return array */ private function get_starter_pages() { $ids = array(); if ( 'page' === get_option( 'show_on_front' ) ) { $front = (int) get_option( 'page_on_front' ); if ( $front > 0 ) { $ids[] = $front; } } $pages = get_posts( array( 'post_type' => 'page', 'post_status' => 'publish', 'numberposts' => 6, 'orderby' => 'modified', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => false, ) ); if ( empty( $pages ) && empty( $ids ) ) { $pages = get_posts( array( 'post_type' => 'post', 'post_status' => 'publish', 'numberposts' => 5, 'orderby' => 'date', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => false, ) ); } $out = array(); $seen = array(); foreach ( array_merge( $ids, (array) $pages ) as $pid ) { $pid = (int) $pid; if ( $pid <= 0 || isset( $seen[ $pid ] ) ) { continue; } $seen[ $pid ] = true; $title = get_the_title( $pid ); $out[] = array( 'id' => $pid, /* translators: %d: post ID, shown when a page has no title. */ 'title' => '' !== $title ? $title : sprintf( __( 'Post #%d', 'ai-schema-gen' ), $pid ), 'url' => (string) get_permalink( $pid ), ); if ( count( $out ) >= 5 ) { break; } } return $out; } /** * Fetch the canonical plan pricing/limits/feature list from the SaaS * backend's public `/app/plans` endpoint, so the Upgrade page and the * usage-limit fallback in UserContext.jsx never drift from whatever the * actual pricing page shows — a plan change only ever has to happen in * `backend/src/lib/plans.ts`, not in a separate plugin release. * * Cached in a transient since this runs on every Schema AI admin page * load; failures fall back to a small hardcoded snapshot rather than * showing an empty Upgrade page if the backend is briefly unreachable. * * @return array */ /** * The site's latest AI Readiness score, recalled from the platform. * * Deliberately recalled, never computed here. The score depends on things * only a crawler standing outside the site can see: whether robots.txt * blocks AI crawlers, whether pages are blank until JavaScript runs, what an * AI model actually answers when asked about the business. A plugin running * inside WordPress cannot observe any of that, so a locally computed number * would be a different number wearing the same name. Anything shown here is * therefore always dated, so nobody reads a week-old score as today's. * * Cached for an hour: the sweep that produces new scores runs weekly, so * asking more often than this only adds latency to an admin page load. * * Returns null when there is no score to show, which the panel renders as an * invitation to run the first check rather than as an error. * * @return array|null { score, grade, checkedAt, reportUrl } or null. */ private function get_readiness_snapshot() { $cached = get_transient( 'schema_ai_readiness_cache' ); if ( is_array( $cached ) ) { return $cached; } // A cached "there is nothing yet" is stored as the string 'none', so a // site that has never been checked does not re-ask on every page load. if ( 'none' === $cached ) { return null; } $settings = get_option( 'schema_ai_settings', array() ); $api_key = ! empty( $settings['api_key'] ) ? $settings['api_key'] : ''; if ( empty( $api_key ) ) { return null; } $response = wp_remote_get( SCHEMA_AI_API_BASE . '/audits/latest?url=' . rawurlencode( home_url( '/' ) ), array( 'timeout' => 8, 'headers' => array( 'X-API-Key' => $api_key ), ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { // Do not cache a failure as "none": a flaky minute should not hide a // real score for the next hour. return null; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); $data = isset( $body['data'] ) && is_array( $body['data'] ) ? $body['data'] : array(); if ( empty( $data ) || ! isset( $data['readinessScore'] ) || null === $data['readinessScore'] ) { set_transient( 'schema_ai_readiness_cache', 'none', HOUR_IN_SECONDS ); return null; } $snapshot = array( 'score' => (int) $data['readinessScore'], 'grade' => isset( $data['grade'] ) ? (string) $data['grade'] : '', 'checkedAt' => isset( $data['recordedAt'] ) ? (string) $data['recordedAt'] : '', 'pagesCrawled' => isset( $data['pagesCrawled'] ) ? (int) $data['pagesCrawled'] : 0, // False when this number came out of an older version of our scoring // formula. Carried through so the panel can say so rather than // presenting it as today's measurement, the same discipline the // report and the monitoring alerts already apply. 'comparable' => ! isset( $data['comparable'] ) || (bool) $data['comparable'], 'reportUrl' => isset( $data['auditJobId'] ) && $data['auditJobId'] ? 'https://www.aischemagen.com/ai-readiness-checker/' . (int) $data['auditJobId'] : '', ); set_transient( 'schema_ai_readiness_cache', $snapshot, HOUR_IN_SECONDS ); return $snapshot; } private function get_plans_data() { $cached = get_transient( 'schema_ai_plans_cache' ); if ( is_array( $cached ) && ! empty( $cached ) ) { return $cached; } $response = wp_remote_get( SCHEMA_AI_API_BASE . '/app/plans', array( 'timeout' => 8 ) ); if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) { $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $body['data']['plans'] ) && is_array( $body['data']['plans'] ) ) { set_transient( 'schema_ai_plans_cache', $body['data']['plans'], 6 * HOUR_IN_SECONDS ); return $body['data']['plans']; } } // Fallback snapshot if the backend is unreachable — kept intentionally // minimal (no descriptive feature bullets) since this is a last resort, // not a second copy to keep in sync. return array( array( 'id' => 'free', 'name' => 'Free', 'monthlyPrice' => 0, 'generationLimit' => 30, 'competitorLimit' => 0, 'teamSeatLimit' => 0, 'siteLimit' => 1, 'supportLabel' => 'Ticket support', 'features' => array() ), array( 'id' => 'pro', 'name' => 'Pro', 'monthlyPrice' => 19, 'generationLimit' => 300, 'competitorLimit' => 2, 'teamSeatLimit' => 0, 'siteLimit' => 1, 'supportLabel' => 'Priority email support', 'features' => array() ), array( 'id' => 'premium', 'name' => 'Premium', 'monthlyPrice' => 49, 'generationLimit' => 1000, 'competitorLimit' => 10, 'teamSeatLimit' => 3, 'siteLimit' => 5, 'supportLabel' => 'Dedicated support', 'features' => array() ), array( 'id' => 'agency', 'name' => 'Agency', 'monthlyPrice' => 99, 'generationLimit' => 3000, 'competitorLimit' => null, 'teamSeatLimit' => null, 'siteLimit' => null, 'supportLabel' => 'Dedicated support', 'features' => array() ), ); } /** * Register the "AI Schema Gen Overview" widget on the main WP Dashboard * (wp-admin's index.php), the highest-visibility screen every admin * already sees on login, same real estate Rank Math/Yoast use for their * own overview widgets. */ public function register_dashboard_widget() { if ( ! current_user_can( 'edit_posts' ) ) { return; } wp_add_dashboard_widget( 'schema_ai_dashboard_widget', __( 'AI Schema Gen Overview', 'ai-schema-gen' ), array( $this, 'render_dashboard_widget' ) ); } /** * Render the WP Dashboard widget: real schema coverage/health numbers * pulled directly from this site's own DB (no network call, so it can't * slow down or break the main Dashboard screen), plus the latest posts * from the AI Schema Gen blog (network call, cached, falls back to * nothing rather than an error if the backend is unreachable). * * None of this admin CSS/JS is enqueued on index.php (it's gated to * Schema AI's own pages), so this prints its own small scoped stylesheet * rather than depending on assets/css/admin.css. */ public function render_dashboard_widget() { global $wpdb; $schemas_table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); $settings = get_option( 'schema_ai_settings', array() ); $post_types = ( ! empty( $settings['auto_generate_post_types'] ) && is_array( $settings['auto_generate_post_types'] ) ) ? $settings['auto_generate_post_types'] : array( 'post', 'page' ); $published_count = 0; foreach ( $post_types as $post_type ) { $counts = wp_count_posts( $post_type ); $published_count += isset( $counts->publish ) ? (int) $counts->publish : 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $schemas_table sanitized via esc_sql(); no user input. $covered_count = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT s.post_id) FROM {$schemas_table} s INNER JOIN {$wpdb->posts} p ON p.ID = s.post_id WHERE p.post_status = 'publish'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $schemas_table sanitized via esc_sql(); no user input. $invalid_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$schemas_table} WHERE validation_status = 'invalid'" ); $entity_score = class_exists( 'Schema_AI_Entity_Settings' ) ? Schema_AI_Entity_Settings::calculate_score() : array( 'score' => 0 ); $missing_count = max( 0, $published_count - $covered_count ); $has_api_key = ! empty( $settings['api_key'] ); $blog_posts = $this->get_latest_blog_posts(); $admin_url = admin_url(); ?>
0 ) : ?>
0 ) : ?>

8 ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { set_transient( 'schema_ai_blog_cache', array(), HOUR_IN_SECONDS ); return array(); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); $posts = ! empty( $body['data'] ) && is_array( $body['data'] ) ? array_slice( $body['data'], 0, 3 ) : array(); $formatted = array(); foreach ( $posts as $post ) { if ( empty( $post['title'] ) || empty( $post['slug'] ) ) { continue; } $published_at = ! empty( $post['publishedAt'] ) ? strtotime( $post['publishedAt'] ) : 0; $formatted[] = array( 'title' => $post['title'], 'url' => 'https://aischemagen.com/blog/' . $post['slug'], 'is_new' => $published_at && $published_at > ( time() - 7 * DAY_IN_SECONDS ), ); } // 12 hours: the blog doesn't publish often enough to need anything fresher. set_transient( 'schema_ai_blog_cache', $formatted, 12 * HOUR_IN_SECONDS ); return $formatted; } /** * Register the schema meta box for post and page editors. * * Also adds the meta box for custom post types if configured in settings. */ public function add_schema_meta_box() { $post_types = array( 'post', 'page' ); // Include additional post types from settings. $settings = get_option( 'schema_ai_settings', array() ); if ( ! empty( $settings['auto_generate_post_types'] ) && is_array( $settings['auto_generate_post_types'] ) ) { $post_types = array_unique( array_merge( $post_types, $settings['auto_generate_post_types'] ) ); } foreach ( $post_types as $post_type ) { add_meta_box( 'ai-schema-gen-meta-box', __( 'AI Schema Gen', 'ai-schema-gen' ), array( $this, 'render_meta_box' ), $post_type, 'normal', 'high' ); } } /** * Render the content of the schema meta box. * * Outputs a nonce field and includes the meta-box partial template * which provides the UI for schema generation and preview. * * @param WP_Post $post The current post object. */ public function render_meta_box( $post ) { wp_nonce_field( 'schema_ai_meta_box', 'schema_ai_meta_box_nonce' ); include plugin_dir_path( __FILE__ ) . 'partials/meta-box.php'; } /** * Save the schema meta box data when a post is saved. * * Verifies the nonce, checks for autosave and permissions, then * sanitizes and stores any schema data submitted from the meta box. * * @param int $post_id The post ID being saved. * @param WP_Post $post The post object being saved. */ public function save_schema_meta_box( $post_id, $post ) { // Verify nonce. if ( ! isset( $_POST['schema_ai_meta_box_nonce'] ) ) { return; } if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['schema_ai_meta_box_nonce'] ) ), 'schema_ai_meta_box' ) ) { return; } // Check for autosave. if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } // Check user permissions. $post_type_object = get_post_type_object( $post->post_type ); if ( ! $post_type_object ) { return; } if ( ! current_user_can( $post_type_object->cap->edit_post, $post_id ) ) { return; } // Check if schema data was submitted. if ( ! isset( $_POST['schema_ai_data'] ) ) { return; } $raw_data = sanitize_text_field( wp_unslash( $_POST['schema_ai_data'] ) ); // Validate that the data is valid JSON. $schema_data = json_decode( $raw_data, true ); if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $schema_data ) ) { return; } // Sanitize the schema data recursively. $schema_data = $this->sanitize_schema_data( $schema_data ); // Save to the custom table. global $wpdb; $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); // Determine schema type. $schema_type = isset( $schema_data['@type'] ) ? sanitize_text_field( $schema_data['@type'] ) : 'Unknown'; // Check if a schema already exists for this post. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $existing = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is sanitized via esc_sql() and not user input. "SELECT id FROM {$table} WHERE post_id = %d AND schema_type = %s LIMIT 1", $post_id, $schema_type ) ); $json_data = wp_json_encode( $schema_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ( $existing ) { // Update existing schema. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (UPDATE), caching not applicable. $wpdb->update( $table, array( 'schema_data' => $json_data, 'validation_status' => 'pending', 'updated_at' => current_time( 'mysql' ), ), array( 'id' => $existing ), array( '%s', '%s', '%s' ), array( '%d' ) ); } else { // Insert new schema. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (INSERT), caching not applicable. $wpdb->insert( $table, array( 'post_id' => $post_id, 'schema_type' => $schema_type, 'schema_data' => $json_data, 'is_auto_generated' => 0, 'validation_status' => 'pending', 'created_at' => current_time( 'mysql' ), 'updated_at' => current_time( 'mysql' ), ), array( '%d', '%s', '%s', '%d', '%s', '%s', '%s' ) ); } // Invalidate the schema cache for this post. if ( class_exists( 'Schema_AI_Schema_Cache' ) ) { $cache = new Schema_AI_Schema_Cache(); $cache->invalidate( $post_id ); } } /** * Display the Dashboard admin page. * * Outputs a wrapper div with a mount point for the React application. */ public function display_dashboard_page() { echo '
'; } /** * Display the Schema Tester admin page. */ public function display_tester_page() { echo '
'; } /** * Display the Upgrade admin page. */ public function display_upgrade_page() { echo '
'; } /** * Display the Settings admin page. * * Includes the settings partial template which handles form display * and submission processing. */ public function display_settings_page() { include plugin_dir_path( __FILE__ ) . 'partials/settings-page.php'; } /** * Display the Setup Wizard page. */ public function display_setup_page() { include plugin_dir_path( __FILE__ ) . 'partials/setup-wizard.php'; } /** * Add plugin action links on the Plugins page. * * Adds "Settings" and "Upgrade" links to the plugin row. * * @param array $links Existing plugin action links. * @return array Modified plugin action links. */ public function add_action_links( $links ) { $settings_link = sprintf( '%s', esc_url( admin_url( 'admin.php?page=ai-schema-gen-settings' ) ), esc_html__( 'Settings', 'ai-schema-gen' ) ); $upgrade_link = sprintf( '%s', esc_url( admin_url( 'admin.php?page=ai-schema-gen-upgrade' ) ), esc_html__( 'Upgrade', 'ai-schema-gen' ) ); array_unshift( $links, $settings_link, $upgrade_link ); return $links; } /** * Build an onclick attribute value that opens a URL in a popup window * (not a plain new tab) — a fixed-size window with no browser chrome. * * href/target stay set on the node too, as a no-JS fallback (and so * middle-click/ctrl-click, which never fire onclick, still work as a * normal new-tab open). * * @param string $url The URL to open. * @param string $name Window name — reusing it means repeat clicks reuse the same popup instead of stacking new ones. * @return string JS for the onclick attribute, including the trailing "return false;". WP_Admin_Bar itself applies esc_js() when rendering this. */ private function popup_window_onclick( $url, $name ) { return 'window.open(' . wp_json_encode( $url ) . ',' . wp_json_encode( $name ) . ',"width=1000,height=800,noopener,noreferrer,resizable,scrollbars"); return false;'; } /** * Add a "Generate Schema" button to the WordPress admin bar on singular pages. * * @param WP_Admin_Bar $wp_admin_bar The admin bar instance. */ public function add_admin_bar_generate( $wp_admin_bar ) { if ( is_admin() || ! is_singular() || ! is_admin_bar_showing() ) { return; } $post_id = get_queried_object_id(); if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { return; } $wp_admin_bar->add_node( array( 'id' => 'ai-schema-gen-generate', // White (via filter — the source SVG is brand teal; brightness(0) // flattens it to black, invert(1) flips that to white, transparent // areas stay transparent). 20px icon + 6px top/bottom padding = // a 32px-tall box, exactly matching the admin bar's row height, // so the icon is truly vertically centered (a floated element // sits at the top of its own box rather than auto-centering // within a taller parent, so the box itself has to add up to // the full row height, not just look close). 'title' => '' . esc_html__( 'Generate Schema', 'ai-schema-gen' ) . '', 'href' => '#', ) ); $page_url = get_permalink( $post_id ); // Dropdown child: Test with Google Rich Results $google_test_url = 'https://search.google.com/test/rich-results?url=' . rawurlencode( $page_url ); $wp_admin_bar->add_node( array( 'id' => 'ai-schema-gen-test-google', 'parent' => 'ai-schema-gen-generate', 'title' => '' . esc_html__( 'Test with Google', 'ai-schema-gen' ), 'href' => $google_test_url, 'meta' => array( 'target' => '_blank', 'rel' => 'noopener noreferrer', 'onclick' => $this->popup_window_onclick( $google_test_url, 'aiSchemaGenTestGoogle' ), ), ) ); // Dropdown child: Validate at Schema.org (the schema.org project's own // official validator, distinct from Google's rich-results-focused tool) $schema_validator_url = 'https://validator.schema.org/#url=' . rawurlencode( $page_url ); $wp_admin_bar->add_node( array( 'id' => 'ai-schema-gen-validate-schema-org', 'parent' => 'ai-schema-gen-generate', 'title' => '' . esc_html__( 'Validate at Schema.org', 'ai-schema-gen' ), 'href' => $schema_validator_url, 'meta' => array( 'target' => '_blank', 'rel' => 'noopener noreferrer', 'onclick' => $this->popup_window_onclick( $schema_validator_url, 'aiSchemaGenValidateSchemaOrg' ), ), ) ); // Dropdown child: Explore Plugin (goes to the plugin dashboard on this site) $wp_admin_bar->add_node( array( 'id' => 'ai-schema-gen-explore', 'parent' => 'ai-schema-gen-generate', 'title' => '' . esc_html__( 'Explore Plugin', 'ai-schema-gen' ), 'href' => admin_url( 'admin.php?page=ai-schema-gen' ), ) ); } /** * Enqueue inline script and styles for the admin bar Generate Schema button. */ public function enqueue_admin_bar_script() { if ( is_admin() || ! is_singular() || ! is_admin_bar_showing() || ! is_user_logged_in() ) { return; } $post_id = get_queried_object_id(); if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { return; } $js = sprintf( '(function(){' . 'var btn=document.getElementById("wp-admin-bar-ai-schema-gen-generate");' . 'if(!btn)return;' . 'var link=btn.querySelector("a");' . 'if(!link)return;' . 'var orig=link.innerHTML;' . 'link.addEventListener("click",function(e){' . 'e.preventDefault();' . 'if(btn.classList.contains("ai-schema-gen-ab-loading"))return;' . 'btn.classList.add("ai-schema-gen-ab-loading");' . 'btn.classList.remove("ai-schema-gen-ab-success","ai-schema-gen-ab-error");' . 'link.innerHTML="Generating\u2026";' . 'fetch("%s",{' . 'method:"POST",' . 'headers:{"Content-Type":"application/json","X-WP-Nonce":"%s"},' . 'credentials:"same-origin",' . 'body:JSON.stringify({post_id:%d})' . '}).then(function(r){return r.json().then(function(d){return{ok:r.ok,data:d}})})' . '.then(function(res){' . 'btn.classList.remove("ai-schema-gen-ab-loading");' . 'if(res.ok&&res.data.success){' . 'btn.classList.add("ai-schema-gen-ab-success");' . 'link.innerHTML="Schema Generated";' . '}else{' . 'btn.classList.add("ai-schema-gen-ab-error");' . 'link.innerHTML="Failed";' . '}' . 'setTimeout(function(){' . 'btn.classList.remove("ai-schema-gen-ab-success","ai-schema-gen-ab-error");' . 'link.innerHTML=orig;' . '},3000);' . '}).catch(function(){' . 'btn.classList.remove("ai-schema-gen-ab-loading");' . 'btn.classList.add("ai-schema-gen-ab-error");' . 'link.innerHTML="Failed";' . 'setTimeout(function(){' . 'btn.classList.remove("ai-schema-gen-ab-error");' . 'link.innerHTML=orig;' . '},3000);' . '});' . '});' . '}());', esc_url( rest_url( 'ai-schema-gen/v1/generate' ) ), wp_create_nonce( 'wp_rest' ), $post_id ); wp_add_inline_script( 'admin-bar', $js ); // NOTE: the resting-state icon is now a real (the logo mark, // see add_admin_bar_generate() above), not a dashicon glyph, so there // is deliberately no ".ab-icon:before { content: ... }" rule for it // here — that content would render as an extra layer on top of the // , not replace it. The loading/success/error transient states // below are unaffected: their markup is injected by the JS below as // literal "dashicons dashicons-*" spans, which is a real dashicon // glyph these ".ab-icon.dashicons" rules correctly still target. $css = '#wp-admin-bar-ai-schema-gen-generate .ab-icon.dashicons{' . 'font-family:dashicons!important;font-size:20px;line-height:1;' . '}' . '.ai-schema-gen-ab-loading .ab-icon.dashicons:before{' . 'content:"\f463"!important;animation:ai-schema-gen-spin 1s linear infinite;' . '}' . '.ai-schema-gen-ab-success #wp-admin-bar-ai-schema-gen-generate>a,' . '#wp-admin-bar-ai-schema-gen-generate.ai-schema-gen-ab-success>a{color:#46b450!important;}' . '.ai-schema-gen-ab-success .ab-icon.dashicons:before{content:"\f12a"!important;}' . '.ai-schema-gen-ab-error #wp-admin-bar-ai-schema-gen-generate>a,' . '#wp-admin-bar-ai-schema-gen-generate.ai-schema-gen-ab-error>a{color:#dc3232!important;}' . '.ai-schema-gen-ab-error .ab-icon.dashicons:before{content:"\f534"!important;}' . '@keyframes ai-schema-gen-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}'; wp_add_inline_style( 'admin-bar', $css ); } /** * Recursively sanitize schema data. * * Walks through the schema array and sanitizes all string values * while preserving the data structure. * * @param array $data The schema data to sanitize. * @return array The sanitized schema data. */ private function sanitize_schema_data( $data ) { $sanitized = array(); foreach ( $data as $key => $value ) { $key = sanitize_text_field( $key ); if ( is_array( $value ) ) { $sanitized[ $key ] = $this->sanitize_schema_data( $value ); } elseif ( is_string( $value ) ) { // Allow URLs to pass through with minimal sanitization. if ( filter_var( $value, FILTER_VALIDATE_URL ) ) { $sanitized[ $key ] = esc_url_raw( $value ); } else { $sanitized[ $key ] = sanitize_text_field( $value ); } } elseif ( is_numeric( $value ) ) { $sanitized[ $key ] = $value; } elseif ( is_bool( $value ) ) { $sanitized[ $key ] = $value; } else { $sanitized[ $key ] = sanitize_text_field( (string) $value ); } } return $sanitized; } /** * Remove all third-party admin notices on our plugin's pages. */ public function suppress_third_party_notices() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only check to identify the current admin page; no form data processed. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; $our_pages = array( 'ai-schema-gen', 'ai-schema-gen-tester', 'ai-schema-gen-settings', 'ai-schema-gen-upgrade', 'ai-schema-gen-setup', ); if ( in_array( $page, $our_pages, true ) ) { remove_all_actions( 'admin_notices' ); remove_all_actions( 'all_admin_notices' ); remove_all_actions( 'user_admin_notices' ); } } }