run_reconcile(); } public function run_reconcile() { if (!class_exists('AIEO_Ratings') || !AIEO_Ratings::supports_external_ids()) { return; } $phase = self::reviews_counted() ? 'ingest' : 'prune'; update_option(self::CURSOR_OPTION, array('cursor' => 0, 'phase' => $phase), false); self::schedule_backfill(); } private function is_review_comment($comment) { if (!is_object($comment) || !isset($comment->comment_type)) { return true; } return ((string) $comment->comment_type === 'review'); } public function on_comment_post($comment_id, $comment_approved = null, $commentdata = null) { if (is_array($commentdata) && isset($commentdata['comment_type']) && (string) $commentdata['comment_type'] !== 'review') { return; } $this->sync_comment((int) $comment_id); } public function on_transition_comment_status($new_status, $old_status, $comment) { if (!is_object($comment) || !isset($comment->comment_ID)) { return; } if (!$this->is_review_comment($comment)) { return; } if ($new_status === 'delete') { $this->withdraw((int) $comment->comment_ID); return; } $this->sync_comment((int) $comment->comment_ID, $comment); } public function on_deleted_comment($comment_id, $comment = null) { if (!$this->is_review_comment($comment)) { return; } $this->withdraw((int) $comment_id); } public function on_edit_comment($comment_id, $data = null) { $this->sync_comment((int) $comment_id); } public function on_comment_meta($meta_id, $object_id, $meta_key, $meta_value = null) { if ($meta_key !== 'rating') { return; } $this->sync_comment((int) $object_id); } public function on_comment_meta_deleted($meta_ids, $object_id, $meta_key, $meta_value = null) { if ($meta_key !== 'rating') { return; } $this->sync_comment((int) $object_id); } public function on_post_comments_bulk_status($post_id) { $this->resync_post((int) $post_id); } private function sync_comment($comment_id, $comment = null) { $comment_id = (int) $comment_id; if ($comment_id <= 0) { return; } if (!class_exists('AIEO_Ratings')) { return; } if ($comment === null || !is_object($comment)) { $comment = get_comment($comment_id); } if (!$comment) { $this->withdraw($comment_id); return; } $eligible = $this->evaluate($comment); if ($eligible === null) { $this->withdraw($comment_id); return; } $sig = 'in:' . $eligible['product_id'] . ':' . $eligible['rating'] . ':' . $eligible['user_id'] . ':' . $eligible['created_at']; if (isset($this->last_state[$comment_id]) && $this->last_state[$comment_id] === $sig) { return; } $this->last_state[$comment_id] = $sig; AIEO_Ratings::instance()->record_external_event( AIEO_Ratings::SOURCE_WC_REVIEW, (string) $comment_id, $eligible['product_id'], $eligible['rating'], $eligible['user_id'], $eligible['created_at'], 'product' ); } private function withdraw($comment_id) { $comment_id = (int) $comment_id; if ($comment_id <= 0 || !class_exists('AIEO_Ratings')) { return; } if (isset($this->last_state[$comment_id]) && $this->last_state[$comment_id] === 'out') { return; } $this->last_state[$comment_id] = 'out'; AIEO_Ratings::instance()->withdraw_external_events( AIEO_Ratings::SOURCE_WC_REVIEW, array((string) $comment_id), 'product' ); } private function resync_post($post_id) { global $wpdb; $post_id = (int) $post_id; if ($post_id <= 0) { return; } if (!class_exists('AIEO_Ratings') || !AIEO_Ratings::supports_external_ids()) { return; } if (get_post_type($post_id) !== 'product') { return; } $events = AIEO_Ratings::events_table(); $mirrored = $wpdb->get_col($wpdb->prepare( "SELECT external_id FROM {$events} WHERE source = %s AND external_id IS NOT NULL AND object_type = 'product' AND product_id = %d", AIEO_Ratings::SOURCE_WC_REVIEW, $post_id )); $rows = $wpdb->get_results($wpdb->prepare( "SELECT c.comment_ID, c.user_id, c.comment_date_gmt, m.meta_value AS rating FROM {$wpdb->comments} c INNER JOIN {$wpdb->commentmeta} m ON m.comment_id = c.comment_ID AND m.meta_key = 'rating' WHERE c.comment_post_ID = %d AND c.comment_approved = '1' AND c.comment_type = 'review' AND c.comment_parent = 0", $post_id ), ARRAY_A); $keep = array(); $payload = array(); foreach ((array) $rows as $r) { if (!is_numeric($r['rating'])) { continue; } $rating = (int) $r['rating']; if ($rating < 1 || $rating > 5) { continue; } $created = (string) $r['comment_date_gmt']; if ($created === '' || $created === '0000-00-00 00:00:00') { $created = current_time('mysql', true); } $keep[(string) $r['comment_ID']] = true; $payload[] = array( 'external_id' => (string) $r['comment_ID'], 'product_id' => $post_id, 'rating' => $rating, 'user_id' => (int) $r['user_id'], 'created_at' => $created, ); } $drop = array(); foreach ((array) $mirrored as $eid) { if (!isset($keep[(string) $eid])) { $drop[] = (string) $eid; } } if (!empty($drop)) { AIEO_Ratings::instance()->withdraw_external_events( AIEO_Ratings::SOURCE_WC_REVIEW, $drop, 'product' ); } if (!empty($payload)) { AIEO_Ratings::instance()->record_external_events_bulk( AIEO_Ratings::SOURCE_WC_REVIEW, $payload, 'product' ); } foreach ((array) $mirrored as $eid) { unset($this->last_state[(int) $eid]); } foreach (array_keys($keep) as $eid) { unset($this->last_state[(int) $eid]); } } public static function reviews_enabled() { return 'yes' === get_option('woocommerce_enable_reviews', 'yes'); } public static function reviews_counted() { if (self::reviews_enabled()) { return true; } if (!class_exists('AIEO_Ratings')) { return false; } $s = AIEO_Ratings::get_settings(); return !empty($s['enabled']); } private function evaluate($comment) { if (!self::reviews_counted()) { return null; } if ((string) $comment->comment_approved !== '1') { return null; } if ((string) $comment->comment_type !== 'review') { return null; } if ((int) $comment->comment_parent !== 0) { return null; } $product_id = (int) $comment->comment_post_ID; if ($product_id <= 0) { return null; } if (get_post_type($product_id) !== 'product') { return null; } $raw = get_comment_meta($comment->comment_ID, 'rating', true); if (!is_numeric($raw)) { return null; } $rating = (int) $raw; if ($rating < 1 || $rating > 5) { return null; } $created = (string) $comment->comment_date_gmt; if ($created === '' || $created === '0000-00-00 00:00:00') { $created = current_time('mysql', true); } return array( 'product_id' => $product_id, 'rating' => $rating, 'user_id' => (int) $comment->user_id, 'created_at' => $created, ); } public static function backfill_batch($after_id = 0, $batch_size = self::BATCH_SIZE) { global $wpdb; $out = array('processed' => 0, 'ingested' => 0, 'skipped' => 0, 'last_id' => (int) $after_id, 'done' => true); if (!class_exists('AIEO_Ratings') || !AIEO_Ratings::supports_external_ids()) { return $out; } if (!self::reviews_counted()) { return $out; } $batch_size = max(1, min(5000, (int) $batch_size)); $rows = $wpdb->get_results($wpdb->prepare( "SELECT c.comment_ID, c.comment_post_ID, c.user_id, c.comment_date_gmt, m.meta_value AS rating FROM {$wpdb->comments} c INNER JOIN {$wpdb->commentmeta} m ON m.comment_id = c.comment_ID AND m.meta_key = 'rating' INNER JOIN {$wpdb->posts} p ON p.ID = c.comment_post_ID AND p.post_type = 'product' WHERE c.comment_approved = '1' AND c.comment_type = 'review' AND c.comment_parent = 0 AND c.comment_ID > %d ORDER BY c.comment_ID ASC LIMIT %d", (int) $after_id, $batch_size ), ARRAY_A); if (empty($rows)) { return $out; } $payload = array(); foreach ($rows as $r) { $out['processed']++; $out['last_id'] = (int) $r['comment_ID']; if (!is_numeric($r['rating'])) { $out['skipped']++; continue; } $rating = (int) $r['rating']; if ($rating < 1 || $rating > 5) { $out['skipped']++; continue; } $created = (string) $r['comment_date_gmt']; if ($created === '' || $created === '0000-00-00 00:00:00') { $created = current_time('mysql', true); } $payload[] = array( 'external_id' => (string) $r['comment_ID'], 'product_id' => (int) $r['comment_post_ID'], 'rating' => $rating, 'user_id' => (int) $r['user_id'], 'created_at' => $created, ); } if (!empty($payload)) { $out['ingested'] = AIEO_Ratings::instance()->record_external_events_bulk( AIEO_Ratings::SOURCE_WC_REVIEW, $payload, 'product' ); } $out['done'] = (count($rows) < $batch_size); return $out; } public static function prune_batch($batch_size = self::BATCH_SIZE) { global $wpdb; $out = array('withdrawn' => 0, 'done' => true); if (!class_exists('AIEO_Ratings') || !AIEO_Ratings::supports_external_ids()) { return $out; } $batch_size = max(1, min(5000, (int) $batch_size)); $events = AIEO_Ratings::events_table(); if (!self::reviews_counted()) { $ids = $wpdb->get_col($wpdb->prepare( "SELECT external_id FROM `{$events}` WHERE source = %s AND external_id IS NOT NULL LIMIT %d", 'wc_review', $batch_size )); $ids = array_values(array_filter(array_map('strval', (array) $ids))); if (!$ids) { return $out; } $out['withdrawn'] = (int) AIEO_Ratings::instance()->withdraw_external_events('wc_review', $ids); $out['done'] = (count($ids) < $batch_size); return $out; } $orphans = $wpdb->get_col($wpdb->prepare( "SELECT e.external_id FROM {$events} e WHERE e.source = %s AND e.external_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM {$wpdb->comments} c INNER JOIN {$wpdb->commentmeta} m ON m.comment_id = c.comment_ID AND m.meta_key = 'rating' INNER JOIN {$wpdb->posts} p ON p.ID = c.comment_post_ID AND p.post_type = 'product' WHERE c.comment_ID = CAST(e.external_id AS UNSIGNED) AND c.comment_approved = '1' AND c.comment_type = 'review' AND c.comment_parent = 0 AND CAST(m.meta_value AS SIGNED) BETWEEN 1 AND 5 ) LIMIT %d", AIEO_Ratings::SOURCE_WC_REVIEW, $batch_size )); if (empty($orphans)) { return $out; } $out['withdrawn'] = AIEO_Ratings::instance()->withdraw_external_events( AIEO_Ratings::SOURCE_WC_REVIEW, (array) $orphans, 'product' ); $out['done'] = (count($orphans) < $batch_size); return $out; } public static function backfill_all($batch_size = self::BATCH_SIZE, $max_batches = 0) { $totals = array('processed' => 0, 'ingested' => 0, 'skipped' => 0, 'withdrawn' => 0, 'batches' => 0); $cursor = 0; $guard = 0; do { $res = self::backfill_batch($cursor, $batch_size); $totals['processed'] += $res['processed']; $totals['ingested'] += $res['ingested']; $totals['skipped'] += $res['skipped']; $totals['batches']++; $cursor = $res['last_id']; $guard++; if ($max_batches > 0 && $guard >= $max_batches) { break; } } while (!$res['done']); do { $p = self::prune_batch($batch_size); $totals['withdrawn'] += $p['withdrawn']; } while (!$p['done']); return $totals; } public static function schedule_backfill() { if (!wp_next_scheduled(self::CRON_HOOK)) { wp_schedule_single_event(time() + 60, self::CRON_HOOK); } } public static function ensure_weekly_schedule($schedules) { if (!isset($schedules['weekly'])) { $schedules['weekly'] = array('interval' => WEEK_IN_SECONDS, 'display' => __('Once Weekly', 'ai-eshop-optimizer')); } return $schedules; } public function run_cron_backfill() { if (!class_exists('AIEO_Ratings') || !AIEO_Ratings::supports_external_ids()) { return; } $state = get_option(self::CURSOR_OPTION, array()); if (!is_array($state)) { $state = array(); } $cursor = isset($state['cursor']) ? (int) $state['cursor'] : 0; $phase = isset($state['phase']) ? (string) $state['phase'] : 'ingest'; if ($phase === 'ingest') { $res = self::backfill_batch($cursor, self::BATCH_SIZE); if ($res['done']) { $state = array('cursor' => $res['last_id'], 'phase' => 'prune'); } else { $state = array('cursor' => $res['last_id'], 'phase' => 'ingest'); } update_option(self::CURSOR_OPTION, $state, false); wp_schedule_single_event(time() + 30, self::CRON_HOOK); return; } $p = self::prune_batch(self::BATCH_SIZE); if ($p['done']) { update_option(self::CURSOR_OPTION, array('cursor' => 0, 'phase' => 'done', 'finished_at' => time()), false); if (!wp_next_scheduled(self::RECONCILE_HOOK)) { wp_schedule_event(time() + WEEK_IN_SECONDS, 'weekly', self::RECONCILE_HOOK); } return; } wp_schedule_single_event(time() + 30, self::CRON_HOOK); } } } if ( defined( 'WP_CLI' ) && WP_CLI && ! class_exists( 'AIEO_CLI_Ratings_WC', false ) ) { class AIEO_CLI_Ratings_WC { public function backfill($args, $assoc) { $batch = isset($assoc['batch']) ? (int) $assoc['batch'] : AIEO_Ratings_WC_Bridge::BATCH_SIZE; $t = AIEO_Ratings_WC_Bridge::backfill_all($batch); \WP_CLI::success(sprintf( 'processed=%d ingested=%d skipped=%d withdrawn=%d batches=%d', $t['processed'], $t['ingested'], $t['skipped'], $t['withdrawn'], $t['batches'] )); } public function prune($args, $assoc) { $total = 0; do { $p = AIEO_Ratings_WC_Bridge::prune_batch(); $total += $p['withdrawn']; } while (!$p['done']); \WP_CLI::success('withdrawn=' . $total); } public function status($args, $assoc) { \WP_CLI::line('schema_version = ' . get_option(AIEO_Ratings::SCHEMA_OPTION, '(none)')); \WP_CLI::line('external_ready = ' . get_option(AIEO_Ratings::EXTERNAL_READY_OPTION, '0')); } } \WP_CLI::add_command('aieo ratings-wc', 'AIEO_CLI_Ratings_WC'); } if ( class_exists( 'AIEO_Ratings_WC_Bridge', false ) ) { AIEO_Ratings_WC_Bridge::instance(); }