prefix . 'schema_ai_schemas'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- SHOW TABLES is safe; result cached implicitly per request; not a SELECT on user data. $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $schemas_table ) ) === $schemas_table; if ( ! $table_exists || get_option( 'schema_ai_db_version' ) !== SCHEMA_AI_DB_VERSION ) { error_log( 'AI Schema Gen: maybe_create_tables() running — table_exists=' . ( $table_exists ? 'yes' : 'no' ) . ' stored_version=' . get_option( 'schema_ai_db_version' ) . ' expected=' . SCHEMA_AI_DB_VERSION ); self::create_tables(); update_option( 'schema_ai_db_version', SCHEMA_AI_DB_VERSION ); } } /** * Create all 5 custom database tables. * * Uses CREATE TABLE IF NOT EXISTS directly via $wpdb->query() rather than * dbDelta() — dbDelta() has a known parsing issue with * "ON UPDATE CURRENT_TIMESTAMP" that causes it to silently skip table * creation while still returning no error. Direct SQL is reliable. */ public static function create_tables() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); error_log( 'AI Schema Gen: create_tables() starting — prefix=' . $wpdb->prefix ); // 1. schema_ai_schemas $table_schemas = $wpdb->prefix . 'schema_ai_schemas'; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared -- Safe DDL on plugin-owned tables only; prefix prevents collisions; no user data involved. $wpdb->query( "CREATE TABLE IF NOT EXISTS {$table_schemas} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT(20) UNSIGNED NOT NULL, schema_type VARCHAR(100) NOT NULL, schema_data LONGTEXT NOT NULL, is_auto_generated TINYINT(1) DEFAULT 0, confidence_score DECIMAL(3,2) DEFAULT NULL, validation_status VARCHAR(20) DEFAULT 'pending', validation_errors TEXT DEFAULT NULL, last_validated DATETIME DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY post_id (post_id), KEY schema_type (schema_type), KEY validation_status (validation_status) ) {$charset_collate}" ); if ( $wpdb->last_error ) { error_log( 'AI Schema Gen: FAILED to create schemas table — ' . $wpdb->last_error ); } else { error_log( 'AI Schema Gen: schemas table OK' ); } // 2. schema_ai_generations $table_generations = $wpdb->prefix . 'schema_ai_generations'; $wpdb->query( "CREATE TABLE IF NOT EXISTS {$table_generations} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT(20) UNSIGNED NOT NULL, post_id BIGINT(20) UNSIGNED DEFAULT NULL, ai_provider VARCHAR(50) NOT NULL, ai_model VARCHAR(50) NOT NULL, input_tokens INT DEFAULT 0, output_tokens INT DEFAULT 0, cost DECIMAL(10,6) DEFAULT 0, response_time INT DEFAULT 0, success TINYINT(1) DEFAULT 1, error_message TEXT DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY user_id (user_id), KEY created_at (created_at), KEY ai_provider (ai_provider) ) {$charset_collate}" ); if ( $wpdb->last_error ) { error_log( 'AI Schema Gen: FAILED to create generations table — ' . $wpdb->last_error ); } // 3. schema_ai_usage $table_usage = $wpdb->prefix . 'schema_ai_usage'; $wpdb->query( "CREATE TABLE IF NOT EXISTS {$table_usage} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT(20) UNSIGNED NOT NULL, date DATE NOT NULL, ai_generations INT DEFAULT 0, manual_generations INT DEFAULT 0, validations INT DEFAULT 0, last_reset DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY user_date (user_id, date), KEY date (date) ) {$charset_collate}" ); if ( $wpdb->last_error ) { error_log( 'AI Schema Gen: FAILED to create usage table — ' . $wpdb->last_error ); } // 4. schema_ai_templates $table_templates = $wpdb->prefix . 'schema_ai_templates'; $wpdb->query( "CREATE TABLE IF NOT EXISTS {$table_templates} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT(20) UNSIGNED NOT NULL, name VARCHAR(200) NOT NULL, description TEXT DEFAULT NULL, schema_type VARCHAR(100) NOT NULL, template_data LONGTEXT NOT NULL, is_public TINYINT(1) DEFAULT 0, uses INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY user_id (user_id), KEY schema_type (schema_type), KEY is_public (is_public) ) {$charset_collate}" ); // 5. schema_ai_analytics $table_analytics = $wpdb->prefix . 'schema_ai_analytics'; $wpdb->query( "CREATE TABLE IF NOT EXISTS {$table_analytics} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT(20) UNSIGNED NOT NULL, schema_id BIGINT(20) UNSIGNED NOT NULL, date DATE NOT NULL, impressions INT DEFAULT 0, clicks INT DEFAULT 0, ctr DECIMAL(5,2) DEFAULT 0, has_rich_result TINYINT(1) DEFAULT 0, validation_errors INT DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY post_schema_date (post_id, schema_id, date), KEY date (date) ) {$charset_collate}" ); // 6. schema_ai_archive_schemas — taxonomy term archives (category/tag // pages). Kept as its own table rather than an ALTER TABLE on // schema_ai_schemas: that table's post_id is NOT NULL and assumed to // be a real WP post ID everywhere it's touched (validator, cache, // admin edit links, coverage counts, bulk generator, REST endpoints); // a term has no post ID at all. One row per term (unlike posts, a // term only ever gets one CollectionPage, not several schema types). $table_archive_schemas = $wpdb->prefix . 'schema_ai_archive_schemas'; $wpdb->query( "CREATE TABLE IF NOT EXISTS {$table_archive_schemas} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, taxonomy VARCHAR(32) NOT NULL, term_id BIGINT(20) UNSIGNED NOT NULL, schema_type VARCHAR(100) NOT NULL, schema_data LONGTEXT NOT NULL, is_auto_generated TINYINT(1) DEFAULT 1, validation_status VARCHAR(20) DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY taxonomy_term (taxonomy, term_id), KEY validation_status (validation_status) ) {$charset_collate}" ); // phpcs:enable // Store database version. update_option( 'schema_ai_db_version', SCHEMA_AI_DB_VERSION ); error_log( 'AI Schema Gen: create_tables() done' ); } /** * Set default plugin options. */ private static function set_default_options() { $defaults = array( 'ai_provider' => 'openai', 'ai_model_free' => 'gpt-4o-mini', 'ai_model_premium' => 'gpt-4o-mini', 'monthly_free_limit' => 30, 'enable_auto_apply' => false, 'enable_caching' => true, 'cache_duration' => 604800, // 1 week in seconds. 'enable_analytics' => false, 'default_organization' => array(), 'default_logo' => '', ); // Only set defaults if option does not exist yet. if ( false === get_option( 'schema_ai_settings' ) ) { update_option( 'schema_ai_settings', $defaults ); } } /** * Schedule cron events. */ private static function schedule_cron_events() { if ( ! wp_next_scheduled( 'schema_ai_daily_cleanup' ) ) { wp_schedule_event( time(), 'daily', 'schema_ai_daily_cleanup' ); } if ( ! wp_next_scheduled( 'schema_ai_usage_reset' ) ) { wp_schedule_event( strtotime( 'tomorrow midnight' ), 'daily', 'schema_ai_usage_reset' ); } if ( ! wp_next_scheduled( 'schema_ai_analytics_sync' ) ) { wp_schedule_event( time(), 'twicedaily', 'schema_ai_analytics_sync' ); } } }