self::POLL_INTERVAL_SECONDS, 'display' => __( 'Every 5 minutes (AI Schema Gen bulk poll)', 'ai-schema-gen' ), ); } return $schedules; } // ------------------------------------------------------------------------- // Instant-start "kick" // ------------------------------------------------------------------------- /** * Option name for the kick token (the shared secret the SaaS backend * presents when it pings this site to start a just-created bulk job * without waiting for the 5-minute poll). * * @var string */ const KICK_TOKEN_OPTION = 'schema_ai_bulk_kick_token'; /** * Transient name for the kick throttle. * * @var string */ const KICK_THROTTLE = 'schema_ai_bulk_kick_lock'; /** * Minimum seconds between two accepted kicks. Defence in depth: the * endpoint is already token-gated, this just caps how often a valid * caller (or a leaked token) can make the site re-poll the backend. * * @var int */ const KICK_THROTTLE_SECONDS = 10; /** * Get this site's kick token, generating and storing one on first use. * * Shipped to the backend inside the sync payload (Schema_AI_Sync::run_sync). * The backend stores it per-site and presents it back in the X-Kick-Token * header when it pings /ai-schema-gen/v1/bulk/poll. Rotating it is as * simple as deleting the option: the next sync mints and reports a new one. * * @return string A 40-char alphanumeric token. */ public static function get_kick_token() { $token = get_option( self::KICK_TOKEN_OPTION, '' ); if ( ! is_string( $token ) || strlen( $token ) < 32 ) { $token = wp_generate_password( 40, false, false ); update_option( self::KICK_TOKEN_OPTION, $token, false ); } return $token; } /** * Verify a presented kick token against the stored one, in constant time. * * @param string $presented The X-Kick-Token header value. * @return bool True when it matches a non-empty stored token. */ public static function verify_kick_token( $presented ) { if ( ! is_string( $presented ) || '' === $presented ) { return false; } $stored = get_option( self::KICK_TOKEN_OPTION, '' ); return is_string( $stored ) && '' !== $stored && hash_equals( $stored, $presented ); } /** * Handle an authenticated kick: schedule an immediate next-page run so a * dashboard-created bulk job starts within seconds instead of on the next * 5-minute poll. Throttled, and a no-op when a rapid run is already queued. * * Deliberately does no work in-request (no backend call, no generation): * it only queues NEXT_HOOK for now. WP-Cron then runs process_next() on * the next request, which the backend's existing per-status-poll ping to * wp-cron.php triggers within a few seconds. * * @return string One of 'scheduled', 'already-queued', 'throttled'. */ public static function handle_kick() { if ( get_transient( self::KICK_THROTTLE ) ) { return 'throttled'; } set_transient( self::KICK_THROTTLE, 1, self::KICK_THROTTLE_SECONDS ); if ( wp_next_scheduled( self::NEXT_HOOK ) ) { return 'already-queued'; } wp_schedule_single_event( time(), self::NEXT_HOOK ); return 'scheduled'; } // ------------------------------------------------------------------------- // Core logic // ------------------------------------------------------------------------- /** * Poll for a pending job and process the next page. * * Called by both the 5-minute background poll and the rapid 3s next-page hook. */ public function process_next() { $api_key = $this->get_api_key(); if ( empty( $api_key ) ) { return; } // Ask the backend for the next pending postId. $response = wp_remote_get( SCHEMA_AI_API_BASE . '/bulk-jobs/pending', array( 'timeout' => 15, 'headers' => array( 'X-API-Key' => $api_key ), ) ); if ( is_wp_error( $response ) ) { return; } $status = wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $status || empty( $data['data'] ) ) { return; // No pending jobs. } $job_id = (int) $data['data']['jobId']; $action = isset( $data['data']['action'] ) ? $data['data']['action'] : 'generate'; $remaining = (int) $data['data']['remaining']; $schema_type = isset( $data['data']['requestedSchemaType'] ) ? (string) $data['data']['requestedSchemaType'] : ''; $previous_type = isset( $data['data']['previousSchemaType'] ) ? (string) $data['data']['previousSchemaType'] : ''; $is_archive = 'generate_archive' === $action; if ( $is_archive ) { $term_id = (int) $data['data']['nextTermId']; $taxonomy = (string) $data['data']['nextTaxonomy']; $generator = new Schema_AI_Generate_Endpoint(); $result = $generator->generate_for_term( $term_id, $taxonomy ); } else { $post_id = (int) $data['data']['nextPostId']; if ( 'delete' === $action ) { $result = $this->delete_schemas_for_post( $post_id, $schema_type ); if ( ! is_wp_error( $result ) ) { // Same hook the save path fires — schedules a near-immediate // sync so the dashboard reflects the removal within seconds. do_action( 'schema_ai_schema_saved', $post_id ); } } else { // $schema_type forces that @type when set (Manage Schemas' deliberate // "add this specific type" action). $previous_type instead means // "this page currently carries this type, let the AI re-decide // whether it's still correct" (the coverage chart's "Regenerate 'X' // Schemas" button, which exists specifically because the type might // be wrong) — generate_for_post_id() classifies fresh and drops the // old entry only if the result actually differs, instead of forcing // it to stay the same. "add" saves alongside whatever schema the // post already has instead of clearing it first. $generator = new Schema_AI_Generate_Endpoint(); $clear_existing = 'add' !== $action; $result = $generator->generate_for_post_id( $post_id, $schema_type, $clear_existing, $previous_type ); } } $success = ! is_wp_error( $result ); $error_code = is_wp_error( $result ) ? $result->get_error_code() : null; $error_message = is_wp_error( $result ) ? $result->get_error_message() : null; // Store debug info so the admin can read it from WP Admin → Tools → Site Health // or directly at yoursite.com/wp-admin/options.php?search=schema_ai_bulk_debug $debug_log = get_option( 'schema_ai_bulk_debug', array() ); $debug_log[] = array( 'time' => gmdate( 'Y-m-d H:i:s' ), 'job_id' => $job_id, 'item' => $is_archive ? "{$taxonomy}:{$term_id}" : $post_id, 'success' => $success, 'code' => $error_code, 'error' => $error_message, ); // Keep only the last 20 entries so the option doesn't grow forever. if ( count( $debug_log ) > 20 ) { $debug_log = array_slice( $debug_log, -20 ); } update_option( 'schema_ai_bulk_debug', $debug_log, false ); // Report progress back to the backend. if ( $is_archive ) { $this->report_archive_progress( $api_key, $job_id, $taxonomy, $term_id, $success, $error_message ); } else { $this->report_progress( $api_key, $job_id, $post_id, $success, $error_message ); } // Once the job's last page is done, sync immediately instead of relying // on the schema_ai_schema_saved hook's 30-second batching delay — that // delay exists to let rapid successive editor saves settle before // syncing, which doesn't apply here: the whole job just finished, so // the dashboard should reflect it within seconds, not half a minute. if ( $remaining <= 1 && class_exists( 'Schema_AI_Sync' ) ) { $sync = new Schema_AI_Sync(); $sync->run_sync(); } // If more pages remain, schedule the next run in 3 seconds. if ( $remaining > 1 ) { if ( ! wp_next_scheduled( self::NEXT_HOOK ) ) { wp_schedule_single_event( time() + 3, self::NEXT_HOOK ); } } } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /** * Report page generation result to the SaaS backend. * * @param string $api_key The SaaS API key. * @param int $job_id The bulk job ID. * @param int $post_id The WordPress post ID that was processed. * @param bool $success Whether generation succeeded. * @param string|null $error_message Error detail when success is false. */ private function report_progress( $api_key, $job_id, $post_id, $success, $error_message = null ) { $body = array( 'postId' => $post_id, 'success' => $success, ); if ( ! $success && ! empty( $error_message ) ) { $body['errorMessage'] = $error_message; } wp_remote_post( SCHEMA_AI_API_BASE . '/bulk-jobs/' . $job_id . '/progress', array( 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json', 'X-API-Key' => $api_key, ), 'body' => wp_json_encode( $body ), ) ); } /** * Report a taxonomy term archive's generation result to the SaaS backend. * Same endpoint as report_progress(), a different payload shape (see * backend/src/api/bulk.ts's progressSchema, which accepts either). * * @param string $api_key The SaaS API key. * @param int $job_id The bulk job ID. * @param string $taxonomy Taxonomy slug. * @param int $term_id WordPress term ID. * @param bool $success Whether generation succeeded. * @param string|null $error_message Error detail when success is false. */ private function report_archive_progress( $api_key, $job_id, $taxonomy, $term_id, $success, $error_message = null ) { $body = array( 'taxonomy' => $taxonomy, 'termId' => $term_id, 'success' => $success, ); if ( ! $success && ! empty( $error_message ) ) { $body['errorMessage'] = $error_message; } wp_remote_post( SCHEMA_AI_API_BASE . '/bulk-jobs/' . $job_id . '/progress', array( 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json', 'X-API-Key' => $api_key, ), 'body' => wp_json_encode( $body ), ) ); } /** * Get the SaaS API key from plugin settings. * * @return string The API key, or empty string if not configured. */ private function get_api_key() { $settings = get_option( 'schema_ai_settings', array() ); return ! empty( $settings['api_key'] ) ? $settings['api_key'] : ''; } /** * Delete schema(s) for a post, optionally limited to a single type. * * Runs as a direct DB write (this executes inside wp-cron, not a REST * request, so it isn't behind the schema endpoint's current_user_can() * checks — this is the dashboard-driven delete path). * * @param int $post_id The WordPress post ID. * @param string $schema_type If set, only delete rows of this type; otherwise delete all schema for the post. * @return true|WP_Error True on success. */ private function delete_schemas_for_post( $post_id, $schema_type = '' ) { global $wpdb; $table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); if ( ! empty( $schema_type ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (DELETE), caching not applicable. $deleted = $wpdb->delete( $table, array( 'post_id' => $post_id, 'schema_type' => $schema_type, ), array( '%d', '%s' ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; write operation (DELETE), caching not applicable. $deleted = $wpdb->delete( $table, array( 'post_id' => $post_id ), array( '%d' ) ); } if ( false === $deleted ) { return new WP_Error( 'schema_ai_delete_failed', __( 'Failed to delete schema.', 'ai-schema-gen' ) ); } // The renderer checks this transient cache before ever touching the DB // table, and the cache key doesn't change just because schema rows did — // without this, the page keeps serving the deleted schema from cache // (up to the configured TTL, a week by default) despite the DB being empty. if ( class_exists( 'Schema_AI_Schema_Cache' ) ) { $cache = new Schema_AI_Schema_Cache(); $cache->invalidate( $post_id ); } return true; } }