15, 'display' => __( 'Every 15 seconds', 'ai-schema-gen' ), ); } return $schedules; } // ------------------------------------------------------------------------- // Core logic // ------------------------------------------------------------------------- /** * Poll for a pending job and process the next page. * * Called by both the 60s 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']; $post_id = (int) $data['data']['nextPostId']; $remaining = (int) $data['data']['remaining']; $action = isset( $data['data']['action'] ) ? $data['data']['action'] : 'generate'; $schema_type = isset( $data['data']['requestedSchemaType'] ) ? (string) $data['data']['requestedSchemaType'] : ''; 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 { // Generate the schema for this post — $schema_type forces that @type // when set (dashboard-driven "regenerate as X"), otherwise the AI // classifies it as usual. "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 ); } $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, 'post_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. $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 ), ) ); } /** * 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; } }