6 * HOUR_IN_SECONDS, 'display' => __( 'Every 6 hours', 'ai-schema-gen' ), ); return $schedules; } // ------------------------------------------------------------------------- // Core sync logic // ------------------------------------------------------------------------- /** * Run a full sync: collect page/schema state and push to the backend. * * Called by wp_cron and also directly after each schema save so the * dashboard reflects changes within seconds. * * @return bool True on success, false on failure or missing API key. */ public function run_sync() { $api_key = $this->get_api_key(); if ( empty( $api_key ) ) { return false; } $pages = $this->collect_pages(); $payload = array( 'siteUrl' => home_url( '/' ), 'siteName' => get_bloginfo( 'name' ), 'pages' => $pages, ); // Include entity authority data so the SaaS dashboard can track it, plus // the local last-changed timestamp so the backend can decide (per // last-write-wins) whether this push or a dashboard edit is newer. if ( class_exists( 'Schema_AI_Entity_Settings' ) ) { $score_data = Schema_AI_Entity_Settings::calculate_score(); $payload['entityScore'] = $score_data['score']; $payload['entityScoreBreakdown'] = $score_data['breakdown']; $payload['entitySettings'] = Schema_AI_Entity_Settings::get_all(); $entity_updated_at = Schema_AI_Entity_Settings::get_updated_at(); if ( '' !== $entity_updated_at ) { $payload['entitySettingsUpdatedAt'] = $entity_updated_at; } } // Back up the plugin's own settings for restore-on-reinstall — minus // the API key itself, which is never stored server-side in plaintext. $plugin_settings = get_option( 'schema_ai_settings', array() ); if ( ! empty( $plugin_settings ) && is_array( $plugin_settings ) ) { unset( $plugin_settings['api_key'] ); $payload['pluginSettings'] = $plugin_settings; } $response = wp_remote_post( SCHEMA_AI_API_BASE . '/sites/sync', array( 'timeout' => 30, 'headers' => array( 'Content-Type' => 'application/json', 'X-API-Key' => $api_key, ), 'body' => wp_json_encode( $payload ), ) ); if ( is_wp_error( $response ) ) { return false; } $status = wp_remote_retrieve_response_code( $response ); if ( 200 !== $status ) { return false; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $data['data']['siteId'] ) ) { update_option( self::SITE_ID_OPTION, (int) $data['data']['siteId'] ); } // Two-way sync: apply the reconciled entity profile the backend returns. // When a dashboard edit was newer than our push, this pulls it down so // the plugin's settings and generated schema match the dashboard. if ( isset( $data['data']['entity'] ) && is_array( $data['data']['entity'] ) ) { $this->reconcile_entity( $data['data']['entity'] ); } return true; } /** * Adopt an entity profile the backend reports as authoritative, if it's * newer than what we have locally. Shared by full sync and the lightweight * admin-load pull. * * @param array $entity { settings: array, updatedAt: string, source: string }. */ private function reconcile_entity( $entity ) { if ( ! class_exists( 'Schema_AI_Entity_Settings' ) ) { return; } if ( empty( $entity['settings'] ) || ! is_array( $entity['settings'] ) ) { return; } $remote_at = isset( $entity['updatedAt'] ) ? (string) $entity['updatedAt'] : ''; if ( '' === $remote_at ) { return; } $local_at = Schema_AI_Entity_Settings::get_updated_at(); // Only apply when the backend's copy is strictly newer than ours, so we // never overwrite a fresh local edit that hasn't been pushed yet. if ( '' !== $local_at && strtotime( $remote_at ) <= strtotime( $local_at ) ) { return; } Schema_AI_Entity_Settings::apply_remote( $entity['settings'], $remote_at ); } // ------------------------------------------------------------------------- // Lightweight admin-load pull (near-instant dashboard → plugin propagation) // ------------------------------------------------------------------------- /** * Transient key throttling how often the admin-load entity pull runs. * * @var string */ const PULL_THROTTLE = 'schema_ai_entity_pull_throttle'; /** * On wp-admin page loads, pull the latest entity profile from the dashboard * at most once every 5 minutes, so edits made in the SaaS dashboard show up * on the WordPress side quickly without waiting for the 6-hour cron. * Skips the heavy page collection a full sync does. */ public function maybe_pull_on_admin() { if ( ! current_user_can( 'manage_options' ) ) { return; } if ( get_transient( self::PULL_THROTTLE ) ) { return; } set_transient( self::PULL_THROTTLE, 1, 5 * MINUTE_IN_SECONDS ); $this->pull_entity(); } /** * Fetch just the entity profile from the backend and apply it if newer. * * @return bool True if a request was made successfully. */ public function pull_entity() { $api_key = $this->get_api_key(); $site_id = (int) get_option( self::SITE_ID_OPTION, 0 ); if ( empty( $api_key ) || $site_id <= 0 ) { return false; } $response = wp_remote_get( SCHEMA_AI_API_BASE . '/sites/' . $site_id . '/entity', array( 'timeout' => 15, 'headers' => array( 'X-API-Key' => $api_key ), ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return false; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $data['data'] ) || ! is_array( $data['data'] ) ) { return false; } $this->reconcile_entity( array( 'settings' => isset( $data['data']['settings'] ) ? $data['data']['settings'] : null, 'updatedAt' => isset( $data['data']['settingsUpdatedAt'] ) ? $data['data']['settingsUpdatedAt'] : '', 'source' => isset( $data['data']['settingsSource'] ) ? $data['data']['settingsSource'] : '', ) ); return true; } /** * Hook called after a schema is saved for a post. * * Schedules a near-immediate single sync so the dashboard updates without * waiting 6 hours. Uses a 30-second delay to let any batch operations * (multiple schemas in one generation) finish first. * * @param int $post_id The post whose schema was just saved. */ public function on_schema_saved( $post_id ) { // Avoid scheduling duplicates if already queued. if ( ! wp_next_scheduled( 'schema_ai_sync_single', array( $post_id ) ) ) { wp_schedule_single_event( time() + 30, 'schema_ai_sync_single', array( $post_id ) ); } } /** * Cron callback for schema_ai_sync_single. * Simply runs a full sync (single-page optimisation not needed at this scale). */ public function run_single_sync( $post_id ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter $this->run_sync(); } // ------------------------------------------------------------------------- // Data collection // ------------------------------------------------------------------------- /** * Collect all published posts/pages with their schema state. * * @return array Array of page objects ready to be JSON-encoded. */ private function collect_pages() { global $wpdb; $post_types = apply_filters( 'schema_ai_sync_post_types', array( 'post', 'page' ) ); // Pull all published posts of the relevant types in one query. $placeholders = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders built from count only; spread operator passes correct number of %s values. $posts = $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_title, post_type FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$placeholders}) ORDER BY ID ASC LIMIT 2000", ...$post_types ) ); // phpcs:enable if ( empty( $posts ) ) { return array(); } // Fetch all schema records for these posts in one query. $post_ids = array_map( 'absint', wp_list_pluck( $posts, 'ID' ) ); $id_placeholders = implode( ', ', array_fill( 0, count( $post_ids ), '%d' ) ); $schema_table = esc_sql( $wpdb->prefix . 'schema_ai_schemas' ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $schema_table sanitized via esc_sql(); $id_placeholders built from count; spread operator passes correct number of %d values. $schema_rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, schema_type, schema_data, is_auto_generated, validation_status FROM {$schema_table} WHERE post_id IN ({$id_placeholders})", ...$post_ids ) ); // phpcs:enable // Index schema rows by post_id for O(1) lookup. $schemas_by_post = array(); foreach ( $schema_rows as $row ) { $pid = (int) $row->post_id; if ( ! isset( $schemas_by_post[ $pid ] ) ) { $schemas_by_post[ $pid ] = array( 'types' => array(), 'entries' => array(), 'status' => null, ); } $schemas_by_post[ $pid ]['types'][] = $row->schema_type; // Full JSON-LD per schema — sent as a backup for restore-on-reinstall. $decoded = json_decode( $row->schema_data, true ); if ( is_array( $decoded ) ) { $schemas_by_post[ $pid ]['entries'][] = array( 'type' => $row->schema_type, 'data' => $decoded, 'isAutoGenerated' => (bool) $row->is_auto_generated, ); } // Aggregate validation status: invalid > warning > valid. $current = $schemas_by_post[ $pid ]['status']; $new = $row->validation_status; if ( 'invalid' === $new || 'invalid' === $current ) { $schemas_by_post[ $pid ]['status'] = 'invalid'; } elseif ( 'warning' === $new || 'warning' === $current ) { $schemas_by_post[ $pid ]['status'] = 'warning'; } else { $schemas_by_post[ $pid ]['status'] = 'valid'; } } $result = array(); foreach ( $posts as $post ) { $pid = (int) $post->ID; $has_schema = isset( $schemas_by_post[ $pid ] ) && ! empty( $schemas_by_post[ $pid ]['types'] ); $result[] = array( 'postId' => $pid, 'title' => $post->post_title, 'url' => get_permalink( $pid ), 'postType' => $post->post_type, 'hasSchema' => $has_schema, 'schemaTypes' => $has_schema ? $schemas_by_post[ $pid ]['types'] : array(), 'schemas' => $has_schema ? $schemas_by_post[ $pid ]['entries'] : array(), 'validationStatus' => $has_schema ? $schemas_by_post[ $pid ]['status'] : null, ); } return $result; } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /** * 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'] : ''; } }