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 ) {
?>
#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();
?>
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' );
}
}
}