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' ) );
// 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 ) )
);
}
/**
* 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 );
$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 ) {
?>
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'] : '',
)
);
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' ),
'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(),
)
);
}
$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,
'activationTime' => (int) get_option( 'schema_ai_activation_time', 0 ),
'dismissBannerNonce' => wp_create_nonce( 'schema_ai_dismiss_business_banner' ),
'entityScore' => $entity_score,
'wpDefaults' => array(
'siteName' => get_bloginfo( 'name' ),
'siteUrl' => home_url( '/' ),
'siteLogo' => $site_logo,
),
'plans' => $this->get_plans_data(),
)
);
}
/**
* 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
*/
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;
}
/**
* 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',
'title' => '' . esc_html__( 'Generate Schema', 'ai-schema-gen' ) . '',
'href' => '#',
)
);
// Dropdown child: Test with Google Rich Results
$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' => 'https://search.google.com/test/rich-results?url=' . rawurlencode( get_permalink( $post_id ) ),
'meta' => array(
'target' => '_blank',
'rel' => 'noopener noreferrer',
),
)
);
// 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 );
$css = '#wp-admin-bar-ai-schema-gen-generate .ab-icon.dashicons{' .
'font-family:dashicons!important;font-size:20px;line-height:1;' .
'}' .
'#wp-admin-bar-ai-schema-gen-generate .ab-icon:before{' .
'content:"\f475";top:3px;' .
'}' .
'.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' );
}
}
}