$state['processed_products'] ?? 0, 'processed_terms_total' => $state['processed_terms'] ?? 0, ) ); return; } self::set_backfill_state( $state ); self::log_debug( 'process_backfill_batch processed batch.', array( 'processed_products_this_run' => count( $product_ids ), 'processed_terms_this_run' => count( $terms ), 'processed_products_total' => $state['processed_products'] ?? 0, 'processed_terms_total' => $state['processed_terms'] ?? 0, ) ); self::schedule_next_run( 30 ); } finally { self::release_backfill_lock(); self::log_debug( 'process_backfill_batch finished run; lock released.' ); } } /** * Ensure a backfill event is scheduled when work remains. */ public static function maybe_schedule_backfill(): void { self::maybe_ensure_database_indexes(); $state = self::get_backfill_state(); if ( empty( $state ) ) { if ( ! self::catalog_needs_backfill() ) { // No work required; mark complete to avoid repeated checks. $complete = self::initial_state(); $complete['stage'] = 'complete'; $complete['completed_at'] = time(); self::set_backfill_state( $complete ); self::log_debug( 'maybe_schedule_backfill: catalog already satisfied; marking state complete.' ); return; } $state = self::initial_state(); self::set_backfill_state( $state ); self::log_debug( 'maybe_schedule_backfill: initialized state for pending catalog backfill.' ); } if ( ! self::has_pending_backfill( $state ) ) { self::log_debug( 'maybe_schedule_backfill: state indicates no pending work.' ); return; } if ( ! wp_next_scheduled( self::BACKFILL_EVENT_HOOK ) ) { self::schedule_next_run( 10, true ); self::log_debug( 'maybe_schedule_backfill: scheduled first backfill run.' ); } } /** * Build a fresh state array for tracking background progress. */ protected static function initial_state(): array { return array( 'stage' => 'running', 'started_at' => time(), 'last_run' => null, 'processed_products' => 0, 'processed_terms' => 0, ); } /** * Reset stored state and clear pending events/locks. */ protected static function reset_backfill_state(): void { self::set_backfill_state( self::initial_state() ); self::clear_scheduled_events(); delete_transient( self::BACKFILL_LOCK_KEY ); self::log_debug( 'Backfill state reset and locks cleared.' ); } /** * Persist the backfill state in the options table. * * @param array $state State payload to store. */ protected static function set_backfill_state( array $state ): void { update_option( self::BACKFILL_STATE_OPTION, $state, false ); } /** * Fetch the stored backfill state. * * @return array */ protected static function get_backfill_state(): array { $state = get_option( self::BACKFILL_STATE_OPTION, array() ); return is_array( $state ) ? $state : array(); } /** * Determine whether backfill still has work to do. * * @param array|null $state Optional state snapshot to evaluate. * @return bool True when work remains, false when complete. */ protected static function has_pending_backfill( ?array $state = null ): bool { $state = $state ?? self::get_backfill_state(); return empty( $state ) || ( $state['stage'] ?? 'running' ) !== 'complete'; } /** * Attempt to acquire the transient-based lock for backfill. * * @return bool True if the lock was acquired, false otherwise. */ protected static function acquire_backfill_lock(): bool { $lock = self::BACKFILL_LOCK_KEY; if ( get_transient( $lock ) ) { self::log_debug( 'acquire_backfill_lock: existing lock detected.' ); return false; } $ttl = self::get_backfill_lock_ttl(); if ( set_transient( $lock, 1, $ttl ) ) { self::log_debug( 'acquire_backfill_lock: lock granted.', array( 'ttl' => $ttl ) ); return true; } self::log_debug( 'acquire_backfill_lock: unable to set lock transient.', array( 'ttl' => $ttl ) ); return false; } /** * Release the transient-based backfill lock. */ protected static function release_backfill_lock(): void { delete_transient( self::BACKFILL_LOCK_KEY ); } /** * Calculate the lock TTL based on batch sizes and filters. * * @return int TTL in seconds. */ protected static function get_backfill_lock_ttl(): int { $context = array( 'products_per_batch' => self::products_per_batch(), 'terms_per_batch' => self::terms_per_batch(), ); $base_ttl = self::BACKFILL_LOCK_TTL_SECONDS; $workload_scaler = max( $context['products_per_batch'] / self::BACKFILL_PRODUCTS_PER_BATCH, $context['terms_per_batch'] / self::BACKFILL_TERMS_PER_BATCH, 1 ); if ( $workload_scaler > 1 ) { $base_ttl = (int) ceil( $base_ttl * $workload_scaler ); } $ttl = (int) apply_filters( 'ai_content_by_parallax_original_snapshot_lock_ttl', $base_ttl, $context ); if ( $ttl < 120 ) { $ttl = 120; } return $ttl; } /** * Resolve products per batch, ensuring a minimum of one. * * @return int */ protected static function products_per_batch(): int { $size = (int) apply_filters( 'ai_content_by_parallax_original_snapshot_products_per_batch', self::BACKFILL_PRODUCTS_PER_BATCH ); if ( $size < 1 ) { $size = 1; } return $size; } /** * Resolve terms per batch, ensuring a minimum of one. * * @return int */ protected static function terms_per_batch(): int { $size = (int) apply_filters( 'ai_content_by_parallax_original_snapshot_terms_per_batch', self::BACKFILL_TERMS_PER_BATCH ); if ( $size < 1 ) { $size = 1; } return $size; } /** * Ensure required database indexes exist for snapshot operations. */ protected static function ensure_database_indexes(): void { global $wpdb; $posts_table = $wpdb->posts; $postmeta_table = $wpdb->postmeta; $termmeta_table = $wpdb->termmeta; $indexes = array( array( 'table' => $posts_table, 'name' => 'ai_content_by_parallax_posts_type_status_id', // Identifiers cannot be placeholders, so we build a safe statement. 'sql' => 'ALTER TABLE ' . esc_sql( $posts_table ) . ' ADD INDEX ai_content_by_parallax_posts_type_status_id (post_type, post_status, ID)', 'description' => 'post_type/post_status lookup for OriginalSnapshot queries', ), array( 'table' => $postmeta_table, 'name' => 'ai_content_by_parallax_postmeta_post_id_meta_key', 'sql' => 'ALTER TABLE ' . esc_sql( $postmeta_table ) . ' ADD INDEX ai_content_by_parallax_postmeta_post_id_meta_key (post_id, meta_key)', 'description' => 'post meta lookup for OriginalSnapshot queries', ), array( 'table' => $postmeta_table, 'name' => 'ai_content_by_parallax_postmeta_meta_key_post_id', 'sql' => 'ALTER TABLE ' . esc_sql( $postmeta_table ) . ' ADD INDEX ai_content_by_parallax_postmeta_meta_key_post_id (meta_key, post_id)', 'description' => 'meta_key/post_id lookup for OriginalSnapshot LEFT JOIN', ), array( 'table' => $termmeta_table, 'name' => 'ai_content_by_parallax_termmeta_meta_key_term_id', 'sql' => 'ALTER TABLE ' . esc_sql( $termmeta_table ) . ' ADD INDEX ai_content_by_parallax_termmeta_meta_key_term_id (meta_key, term_id)', 'description' => 'meta_key/term_id lookup for OriginalSnapshot restore candidate scans', ), ); foreach ( $indexes as $index ) { $table = (string) $index['table']; $name = (string) $index['name']; $sql = (string) $index['sql']; // Cached schema lookup (1 day) via index_exists(). if ( self::index_exists( $table, $name ) ) { self::log_debug( 'ensure_database_indexes: index already present.', array( 'table' => $table, 'index' => $name, ) ); continue; } // Attempt schema change. This is intentionally a direct query. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- DDL; identifiers cannot be parameterized; statement is constructed from allow-listed tables and fixed index names. $result = $wpdb->query( $sql ); if ( false === $result ) { self::log_debug( 'ensure_database_indexes: failed to create index.', array( 'table' => $table, 'index' => $name, 'error' => $wpdb->last_error, ) ); continue; } // Invalidate the cached index_exists() result for this index (it may have been cached as "missing"). $cache_group = 'ai_content_by_parallax'; $cache_key = 'index_exists:' . md5( $wpdb->dbname . '|' . $table . '|' . $name ); wp_cache_delete( $cache_key, $cache_group ); self::log_debug( 'ensure_database_indexes: index created.', array( 'table' => $table, 'index' => $name, 'description' => (string) ( $index['description'] ?? '' ), ) ); } } /** * Check if a given index exists on a table. * * @param string $table The database table name. * @param string $index_name The index name to check for. * @return bool True if the index exists, false otherwise. */ protected static function index_exists( string $table, string $index_name ): bool { global $wpdb; $allowed_tables = array( $wpdb->posts, $wpdb->postmeta, $wpdb->terms, $wpdb->termmeta, $wpdb->term_taxonomy, ); if ( ! in_array( $table, $allowed_tables, true ) ) { return false; } $cache_group = 'ai_content_by_parallax'; $cache_key = 'index_exists:' . md5( $wpdb->dbname . '|' . $table . '|' . $index_name ); $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached ) { return (bool) $cached; } // INFORMATION_SCHEMA allows binding TABLE_NAME/INDEX_NAME safely with placeholders. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Read-only schema lookup; cached via wp_cache_*. $found = $wpdb->get_var( $wpdb->prepare( ' SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s LIMIT 1 ', $wpdb->dbname, $table, $index_name ) ); $exists = ! empty( $found ); // Cache result for 1 day. Index presence changes rarely. wp_cache_set( $cache_key, $exists ? 1 : 0, $cache_group, DAY_IN_SECONDS ); return $exists; } /** * Ensure database indexes are created only once per request. */ protected static function maybe_ensure_database_indexes(): void { static $ensured = false; if ( $ensured ) { return; } $ensured = true; self::ensure_database_indexes(); } /** * Clear all scheduled backfill events. */ protected static function clear_scheduled_events(): void { $cleared = 0; $max_iterations = 100; // Safety limit to prevent infinite loops. $iterations = 0; do { $event = wp_get_scheduled_event( self::BACKFILL_EVENT_HOOK ); if ( $event && isset( $event->timestamp ) ) { wp_unschedule_event( $event->timestamp, self::BACKFILL_EVENT_HOOK, $event->args ?? array() ); ++$cleared; } ++$iterations; } while ( $event && $iterations < $max_iterations ); if ( $cleared > 0 ) { self::log_debug( 'Cleared scheduled backfill events.', array( 'count' => $cleared ) ); } } /** * Schedule the next backfill run if none is pending. * * @param int $delay_seconds Delay in seconds before the next run. * @param bool $force Whether to force scheduling even if one exists. */ protected static function schedule_next_run( int $delay_seconds = 60, bool $force = false ): void { $delay_seconds = max( 5, $delay_seconds ); if ( $force || ! wp_next_scheduled( self::BACKFILL_EVENT_HOOK ) ) { $timestamp = time() + $delay_seconds; wp_schedule_single_event( $timestamp, self::BACKFILL_EVENT_HOOK ); self::log_debug( 'Scheduled backfill run.', array( 'timestamp' => $timestamp, 'delay_seconds' => $delay_seconds, 'forced' => $force, ) ); } } /** * Check if the catalog needs backfill of original snapshots. */ protected static function catalog_needs_backfill(): bool { if ( ! empty( self::get_products_needing_snapshot( 1 ) ) ) { return true; } if ( ! empty( self::get_terms_needing_snapshot( 1 ) ) ) { return true; } return false; } /** * Fetch product IDs needing original snapshots. * * @param int $limit Number of products to fetch. * @return array Array of product IDs. */ protected static function get_products_needing_snapshot( int $limit ): array { global $wpdb; $limit = max( 1, $limit ); $statuses = array( 'publish', 'pending', 'draft', 'future', 'private' ); $cache_group = 'ai_content_by_parallax'; $cache_key = 'products_needing_snapshot:' . md5( wp_json_encode( array( $limit, $statuses, self::POST_META_KEY ) ) ); $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached ) { return array_values( array_unique( array_map( 'intval', (array) $cached ) ) ); } $placeholders = implode( ',', array_fill( 0, count( $statuses ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Dynamic placeholder list (%s, %s, ...) is safe; values are bound via prepare(). $sql = " SELECT p.ID FROM {$wpdb->posts} AS p LEFT JOIN {$wpdb->postmeta} AS pm ON pm.post_id = p.ID AND pm.meta_key = %s WHERE p.post_type = 'product' AND p.post_status IN ($placeholders) AND pm.post_id IS NULL ORDER BY p.ID ASC LIMIT %d "; $args = array_merge( array( self::POST_META_KEY ), $statuses, array( $limit ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Required for efficient backfill scan; results cached via wp_cache_*. $rows = $wpdb->get_col( $wpdb->prepare( $sql, $args ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Placeholders used; prepared inline. ); $rows = array_values( array_unique( array_map( 'intval', (array) $rows ) ) ); wp_cache_set( $cache_key, $rows, $cache_group, 30 ); return $rows; } /** * Fetch terms needing original snapshots. * * @param int $limit Number of terms to fetch. * @return array Array of term data. */ protected static function get_terms_needing_snapshot( int $limit ): array { global $wpdb; $limit = max( 1, $limit ); $taxonomies = self::get_product_taxonomies(); if ( empty( $taxonomies ) ) { return array(); } // Cache briefly to satisfy Plugin Check's "NoCaching" warning. $cache_group = 'ai_content_by_parallax'; $cache_key = 'terms_needing_snapshot:' . md5( wp_json_encode( array( $limit, $taxonomies, self::TERM_META_KEY ) ) ); $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached && is_array( $cached ) ) { // Cached payload should already be the final normalized array shape. return $cached; } $placeholders = implode( ',', array_fill( 0, count( $taxonomies ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Dynamic placeholder list (%s, %s, ...) is safe; values are passed via $wpdb->prepare(). $sql = " SELECT tt.term_id, tt.taxonomy FROM {$wpdb->term_taxonomy} AS tt LEFT JOIN {$wpdb->termmeta} AS tm ON tm.term_id = tt.term_id AND tm.meta_key = %s WHERE tt.taxonomy IN ($placeholders) AND tm.term_id IS NULL ORDER BY tt.term_id ASC LIMIT %d "; // Build args in order of placeholders in $sql: // 1) meta_key, 2..n) taxonomies, last) limit. $args = array_merge( array( self::TERM_META_KEY ), $taxonomies, array( $limit ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct read required and cached. $rows = $wpdb->get_results( $wpdb->prepare( $sql, $args ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Dynamic placeholder list is safe; values are prepared. ARRAY_A ); if ( empty( $rows ) ) { wp_cache_set( $cache_key, array(), $cache_group, 30 ); return array(); } self::log_debug( 'get_terms_needing_snapshot: found terms requiring snapshot.', array( 'count' => count( $rows ) ) ); $out = array_map( static function ( array $row ): array { return array( 'term_id' => (int) ( $row['term_id'] ?? 0 ), 'taxonomy' => (string) ( $row['taxonomy'] ?? '' ), ); }, (array) $rows ); // Cache for 30 seconds; missing snapshots can change as backfill runs. wp_cache_set( $cache_key, $out, $cache_group, 30 ); return $out; } /** * Get all product-related taxonomies, including attributes. * * @return array Array of taxonomy names. */ protected static function get_product_taxonomies(): array { $taxes = array( 'product_cat', 'product_tag' ); if ( function_exists( 'wc_get_attribute_taxonomies' ) ) { $attrs = wc_get_attribute_taxonomies(); if ( ! empty( $attrs ) ) { foreach ( $attrs as $attr ) { if ( ! empty( $attr->attribute_name ) ) { $taxes[] = wc_attribute_taxonomy_name( $attr->attribute_name ); } } } } $taxes = array_unique( array_filter( $taxes ) ); return array_values( $taxes ); } /** * Helper: log debug messages when plugin debug is enabled. * * @param string $message Log message. * @param mixed|null $payload Optional payload data. */ protected static function log_debug( string $message, $payload = null ): void { if ( ! self::is_debug_enabled() ) { return; } Logger::write_log( 'info', '[OriginalSnapshot] ' . $message, $payload ); } /** * Check if debug logging is enabled via global or settings. */ protected static function is_debug_enabled(): bool { if ( ! self::ENABLE_LOGGING ) { return false; } static $cached = null; if ( null !== $cached ) { return $cached; } if ( isset( $GLOBALS['ai_content_by_parallax_plugin_debug_enabled'] ) ) { $cached = (bool) $GLOBALS['ai_content_by_parallax_plugin_debug_enabled']; return $cached; } if ( class_exists( Settings::class ) ) { $cached = (bool) Settings::get( 'enable_debug_support', false ); return $cached; } $cached = false; return $cached; } /** * Save an original snapshot for a product if missing (or force refresh). * * @param int $product_id Product ID. * @param bool $force Whether to overwrite an existing snapshot. * @return bool True on success or when already present. */ public static function save_original_if_missing_product( int $product_id, bool $force = false ): bool { $existing = get_post_meta( $product_id, self::POST_META_KEY, true ); if ( $existing ) { $current = self::decode_snapshot_value( $existing ); if ( is_array( $current ) && ! array_key_exists( 'parallax_meta', $current ) ) { $current['parallax_meta'] = self::get_parallax_meta_for_product( $product_id ); update_post_meta( $product_id, self::POST_META_KEY, maybe_serialize( $current ) ); } // If not forcing, just patch and return. if ( ! $force ) { return true; } // If forcing, continue to overwrite with new payload below. } $post = get_post( $product_id ); if ( ! $post || 'product' !== $post->post_type ) { return false; } $payload = array( 'type' => 'product', 'post_id' => $product_id, 'title' => (string) ( $post->post_title ?? '' ), 'description' => (string) ( $post->post_content ?? '' ), 'excerpt' => (string) ( $post->post_excerpt ?? '' ), 'seo' => self::get_seo_meta_for_post( $product_id ), 'parallax_meta' => self::get_parallax_meta_for_product( $product_id ), 'captured_at' => gmdate( 'c' ), 'wp_version' => get_bloginfo( 'version' ), 'plugin' => 'ai-content-by-parallax', ); return (bool) update_post_meta( $product_id, self::POST_META_KEY, maybe_serialize( $payload ) ); } /** * Save an original snapshot for a term if missing (or force refresh). * * @param int $term_id Term ID. * @param string $taxonomy Term taxonomy. * @param bool $force Whether to force overwrite existing snapshot. * @return bool True on success, false on failure. */ public static function save_original_if_missing_term( int $term_id, string $taxonomy, bool $force = false ): bool { $existing = get_term_meta( $term_id, self::TERM_META_KEY, true ); if ( $existing && ! $force ) { $current = self::decode_snapshot_value( $existing ); if ( is_array( $current ) && ! array_key_exists( 'parallax_meta', $current ) ) { $current['parallax_meta'] = self::get_parallax_meta_for_term( $term_id ); update_term_meta( $term_id, self::TERM_META_KEY, maybe_serialize( $current ) ); } return true; } $term = get_term( $term_id, $taxonomy ); if ( ! $term instanceof WP_Term ) { return false; } $payload = array( 'type' => $taxonomy, 'term_id' => $term_id, 'name' => (string) ( $term->name ?? '' ), 'description' => (string) ( $term->description ?? '' ), 'seo' => self::get_seo_meta_for_term( $term_id, $taxonomy ), 'parallax_meta' => self::get_parallax_meta_for_term( $term_id ), 'captured_at' => gmdate( 'c' ), 'wp_version' => get_bloginfo( 'version' ), 'plugin' => 'ai-content-by-parallax', ); return (bool) update_term_meta( $term_id, self::TERM_META_KEY, maybe_serialize( $payload ) ); } /** * Force-refresh every stored product + term snapshot with current data. * Returns [processed_products, processed_terms]. */ public static function retake_original_snapshots(): array { self::maybe_ensure_database_indexes(); $products = self::retake_all_product_snapshots(); $terms = self::retake_all_term_snapshots(); self::log_debug( 'retake_original_snapshots completed.', array( 'products_processed' => $products, 'terms_processed' => $terms, ) ); return array( $products, $terms ); } /** * Retake original snapshots for all eligible products. * * @return int Number of products processed. */ protected static function retake_all_product_snapshots(): int { $product_ids = self::get_all_snapshot_eligible_products(); if ( empty( $product_ids ) ) { return 0; } $processed = 0; foreach ( array_chunk( $product_ids, self::RESTORE_CHUNK_SIZE ) as $chunk ) { foreach ( $chunk as $product_id ) { self::save_original_if_missing_product( (int) $product_id, true ); ++$processed; } } return $processed; } /** * Retake original snapshots for all eligible terms. * * @return int Number of terms processed. */ protected static function retake_all_term_snapshots(): int { $terms = self::get_all_snapshot_eligible_terms(); if ( empty( $terms ) ) { return 0; } $processed = 0; foreach ( array_chunk( $terms, self::RESTORE_CHUNK_SIZE ) as $chunk ) { foreach ( $chunk as $term ) { $term_id = (int) ( $term['term_id'] ?? 0 ); $taxonomy = (string) ( $term['taxonomy'] ?? '' ); if ( $term_id <= 0 || '' === $taxonomy ) { continue; } self::save_original_if_missing_term( $term_id, $taxonomy, true ); ++$processed; } } return $processed; } /** * Get all products eligible for snapshotting. * * @return int[] */ protected static function get_all_snapshot_eligible_products(): array { global $wpdb; $statuses = array( 'publish', 'pending', 'draft', 'future', 'private' ); $cache_group = 'ai_content_by_parallax'; $cache_key = 'all_snapshot_eligible_products:' . md5( wp_json_encode( $statuses ) ); $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached ) { return array_map( 'intval', (array) $cached ); } $placeholders = implode( ',', array_fill( 0, count( $statuses ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Dynamic placeholder list is safe; values are passed via $wpdb->prepare(). $sql = " SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product' AND post_status IN ($placeholders) ORDER BY ID ASC "; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct read required and cached. $rows = $wpdb->get_col( $wpdb->prepare( $sql, $statuses ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Dynamic placeholder list is safe; values are prepared. ); $rows = array_map( 'intval', (array) $rows ); // Cache for 5 minutes; eligibility changes, but not second-to-second. wp_cache_set( $cache_key, $rows, $cache_group, 5 * MINUTE_IN_SECONDS ); return $rows; } /** * Get all terms eligible for snapshotting. * * @return array */ protected static function get_all_snapshot_eligible_terms(): array { global $wpdb; $taxonomies = self::get_product_taxonomies(); if ( empty( $taxonomies ) ) { return array(); } $cache_group = 'ai_content_by_parallax'; $cache_key = 'all_snapshot_eligible_terms:' . md5( wp_json_encode( $taxonomies ) ); $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached && is_array( $cached ) ) { return $cached; } $placeholders = implode( ',', array_fill( 0, count( $taxonomies ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Dynamic placeholder list is safe; values are passed via $wpdb->prepare(). $sql = " SELECT term_id, taxonomy FROM {$wpdb->term_taxonomy} WHERE taxonomy IN ($placeholders) ORDER BY term_id ASC "; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct read required and cached. $rows = $wpdb->get_results( $wpdb->prepare( $sql, $taxonomies ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Dynamic placeholder list is safe; values are prepared. ARRAY_A ); $rows = is_array( $rows ) ? $rows : array(); $out = array_map( static function ( array $row ): array { return array( 'term_id' => (int) ( $row['term_id'] ?? 0 ), 'taxonomy' => (string) ( $row['taxonomy'] ?? '' ), ); }, $rows ); // Cache for 5 minutes; eligibility changes, but not second-to-second. wp_cache_set( $cache_key, $out, $cache_group, 5 * MINUTE_IN_SECONDS ); return $out; } /** * Restore every product and product term from stored originals. * Returns [restored_products_count, restored_terms_count] */ public static function restore_all_from_originals(): array { $p = self::restore_all_products_from_originals(); $t = self::restore_all_terms_from_originals(); return array( $p, $t ); } /** * Restore only products from stored originals (and backups). * * @return int Number of products restored. */ public static function restore_products_from_originals(): int { return self::restore_all_products_from_originals(); } /** * Restore only product categories from stored originals. * * @return int Number of categories restored. */ public static function restore_categories_from_originals(): int { return self::restore_terms_from_originals( array( 'product_cat' ) ); } /** * Restore all products from stored original snapshots (and backups). * * @return int Number of products restored. */ protected static function restore_all_products_from_originals(): int { global $wpdb; $meta_keys = array( self::POST_META_KEY, self::PREV_PAYLOAD_META_KEY ); $cache_group = 'ai_content_by_parallax'; $cache_key = 'restore_all_products_candidates:' . md5( wp_json_encode( $meta_keys ) ); $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached ) { $product_ids = array_values( array_unique( array_map( 'intval', (array) $cached ) ) ); } else { $placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Dynamic placeholder list is safe; values are passed via $wpdb->prepare(). $sql = " SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key IN ($placeholders) "; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct read required; cached via wp_cache_*. $product_ids = $wpdb->get_col( $wpdb->prepare( $sql, $meta_keys ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Dynamic placeholder list is safe; values are prepared. ); $product_ids = array_values( array_unique( array_map( 'intval', (array) $product_ids ) ) ); // Cache briefly; candidates may change as meta is added/removed. wp_cache_set( $cache_key, $product_ids, $cache_group, MINUTE_IN_SECONDS ); } Logger::write_log( 'info', '[OriginalSnapshot] restore_all_products_from_originals candidates=' . count( $product_ids ) ); if ( ! $product_ids ) { return 0; } $count = 0; foreach ( array_chunk( $product_ids, self::RESTORE_CHUNK_SIZE ) as $chunk ) { $prefetched = self::prefetch_product_restore_payloads( $chunk ); foreach ( $chunk as $product_id ) { $pid = (int) $product_id; $payload = $prefetched[ $pid ] ?? array( 'snapshot' => null, 'backup' => null, ); $snapshot = ( isset( $payload['snapshot'] ) && is_array( $payload['snapshot'] ) ) ? $payload['snapshot'] : null; $backup = ( isset( $payload['backup'] ) && is_array( $payload['backup'] ) ) ? $payload['backup'] : null; if ( self::restore_product( $pid, $snapshot, $backup ) ) { ++$count; } } } Logger::write_log( 'info', '[OriginalSnapshot] restore_all_products_from_originals restored=' . $count ); // Invalidate cache after running restore since postmeta set may have changed. wp_cache_delete( $cache_key, $cache_group ); return $count; } /** * Restore all terms from stored original snapshots. * * @return int Number of terms restored. */ protected static function restore_all_terms_from_originals(): int { return self::restore_terms_from_originals(); } /** * Restore terms from stored original snapshots with optional taxonomy filter. * * @param array $allowed_taxonomies Limit restore to these taxonomies. Empty restores all. * @return int Number of terms restored. */ protected static function restore_terms_from_originals( array $allowed_taxonomies = array() ): int { global $wpdb; $meta_keys = array( self::TERM_META_KEY, '_parallax_meta_title', '_parallax_meta_desc', '_parallax_short_desc' ); $allowed_taxonomies = array_values( array_filter( array_map( 'sanitize_key', $allowed_taxonomies ) ) ); $filter_taxonomies = ! empty( $allowed_taxonomies ); $cache_group = 'ai_content_by_parallax'; $cache_key = 'restore_terms_candidates:' . md5( wp_json_encode( array( $meta_keys, $allowed_taxonomies ) ) ); $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached ) { $term_rows = array_map( static function ( array $row ): array { return array( 'term_id' => (int) ( $row['term_id'] ?? 0 ), 'taxonomy' => (string) ( $row['taxonomy'] ?? '' ), ); }, (array) $cached ); } else { $meta_placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) ); $tax_placeholders = $filter_taxonomies ? implode( ',', array_fill( 0, count( $allowed_taxonomies ), '%s' ) ) : ''; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Placeholder list is safe; values prepared below. $tax_clause = $filter_taxonomies ? sprintf( ' AND tt.taxonomy IN (%s)', $tax_placeholders ) : ''; $args = $meta_keys; if ( $filter_taxonomies ) { $args = array_merge( $args, $allowed_taxonomies ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Required for efficient candidate scan; dynamic placeholders are prepared. $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic placeholder list is safe; values are prepared. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Placeholder lists are constructed locally; values are prepared. sprintf( "SELECT DISTINCT tm.term_id, tt.taxonomy FROM {$wpdb->termmeta} AS tm INNER JOIN {$wpdb->term_taxonomy} AS tt ON tt.term_id = tm.term_id WHERE tm.meta_key IN (%s)%s ORDER BY tm.term_id ASC", $meta_placeholders, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Placeholder list is safe. $tax_clause // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Placeholder list is safe. ), $args ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Dynamic placeholder list is safe; values are prepared. ARRAY_A ); $rows = is_array( $rows ) ? $rows : array(); $term_rows = array_map( static function ( array $row ): array { return array( 'term_id' => (int) ( $row['term_id'] ?? 0 ), 'taxonomy' => (string) ( $row['taxonomy'] ?? '' ), ); }, $rows ); // Cache briefly; candidates may change as meta is added/removed. wp_cache_set( $cache_key, $term_rows, $cache_group, MINUTE_IN_SECONDS ); } $term_rows = array_values( array_filter( $term_rows, static function ( array $row ): bool { return ! empty( $row['term_id'] ); } ) ); $term_rows = array_values( array_unique( $term_rows, SORT_REGULAR ) ); Logger::write_log( 'info', '[OriginalSnapshot] restore_terms_from_originals candidates=' . count( $term_rows ), array( 'filter_taxonomies' => $allowed_taxonomies, ) ); if ( ! $term_rows ) { return 0; } $count = 0; foreach ( array_chunk( $term_rows, self::RESTORE_CHUNK_SIZE ) as $chunk ) { $chunk_ids = array_map( static function ( array $row ): int { return (int) ( $row['term_id'] ?? 0 ); }, $chunk ); $snapshot_map = self::prefetch_term_restore_payloads( $chunk_ids ); foreach ( $chunk as $term_row ) { $tid = isset( $term_row['term_id'] ) ? (int) $term_row['term_id'] : 0; $taxonomy = isset( $term_row['taxonomy'] ) ? (string) $term_row['taxonomy'] : ''; $snapshot = $snapshot_map[ $tid ] ?? null; if ( '' === $taxonomy && is_array( $snapshot ) && ! empty( $snapshot['type'] ) ) { $taxonomy = (string) $snapshot['type']; } if ( '' === $taxonomy ) { $taxonomy = 'product_cat'; } if ( self::restore_term( $tid, $taxonomy, is_array( $snapshot ) ? $snapshot : null ) ) { ++$count; } } } Logger::write_log( 'info', '[OriginalSnapshot] restore_terms_from_originals restored=' . $count, array( 'filter_taxonomies' => $allowed_taxonomies, ) ); // Invalidate cache after running restore since termmeta set may have changed. wp_cache_delete( $cache_key, $cache_group ); return $count; } /** * Prefetch snapshot + backup payloads for a batch of products. * * @param int[] $product_ids Product IDs. * @return array */ protected static function prefetch_product_restore_payloads( array $product_ids ): array { global $wpdb; $product_ids = array_values( array_unique( array_filter( array_map( 'intval', $product_ids ) ) ) ); if ( ! $product_ids ) { return array(); } $cache_group = 'ai_content_by_parallax'; $cache_key = 'prefetch_product_restore_payloads:' . md5( wp_json_encode( array( self::POST_META_KEY, self::PREV_PAYLOAD_META_KEY, $product_ids, ) ) ); $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached && is_array( $cached ) ) { return $cached; } $placeholders = implode( ',', array_fill( 0, count( $product_ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Dynamic placeholder list is safe; values are passed via $wpdb->prepare(). $sql = " SELECT post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE meta_key IN (%s, %s) AND post_id IN ($placeholders) "; // Args follow placeholders order: %s, %s, then all %d IDs. $prepare_args = array_merge( array( $sql, self::POST_META_KEY, self::PREV_PAYLOAD_META_KEY ), $product_ids ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct read required and cached. $rows = $wpdb->get_results( $wpdb->prepare( $sql, $prepare_args ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Dynamic placeholder list is safe; values are prepared. ARRAY_A ); $rows = is_array( $rows ) ? $rows : array(); $out = array(); foreach ( $rows as $row ) { $pid = isset( $row['post_id'] ) ? (int) $row['post_id'] : 0; if ( $pid <= 0 ) { continue; } if ( ! isset( $out[ $pid ] ) ) { $out[ $pid ] = array( 'snapshot' => null, 'backup' => null, ); } $meta_key = (string) ( $row['meta_key'] ?? '' ); $meta_value = $row['meta_value'] ?? null; if ( self::POST_META_KEY === $meta_key ) { $out[ $pid ]['snapshot'] = self::decode_snapshot_value( $meta_value ); } elseif ( self::PREV_PAYLOAD_META_KEY === $meta_key ) { $out[ $pid ]['backup'] = self::decode_prev_payload_value( $meta_value ); } } // Cache for 1 minute; used during restore runs. wp_cache_set( $cache_key, $out, $cache_group, MINUTE_IN_SECONDS ); return $out; } /** * Prefetch snapshot payloads for a batch of terms. * * @param int[] $term_ids Term IDs. * @return array */ protected static function prefetch_term_restore_payloads( array $term_ids ): array { global $wpdb; $term_ids = array_values( array_unique( array_filter( array_map( 'intval', $term_ids ) ) ) ); if ( ! $term_ids ) { return array(); } $cache_group = 'ai_content_by_parallax'; $cache_key = 'prefetch_term_restore_payloads:' . md5( wp_json_encode( array( self::TERM_META_KEY, $term_ids ) ) ); $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached && is_array( $cached ) ) { return $cached; } $placeholders = implode( ',', array_fill( 0, count( $term_ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Dynamic placeholder list is safe; values are passed via $wpdb->prepare(). $sql = " SELECT term_id, meta_value FROM {$wpdb->termmeta} WHERE meta_key = %s AND term_id IN ($placeholders) "; $args = array_merge( array( self::TERM_META_KEY ), $term_ids ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct read required and cached. $rows = $wpdb->get_results( $wpdb->prepare( $sql, $args ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Dynamic placeholder list is safe; values are prepared. ARRAY_A ); $rows = is_array( $rows ) ? $rows : array(); $out = array(); foreach ( $rows as $row ) { $tid = isset( $row['term_id'] ) ? (int) $row['term_id'] : 0; if ( $tid <= 0 ) { continue; } $decoded = self::decode_snapshot_value( $row['meta_value'] ?? null ); if ( is_array( $decoded ) ) { $out[ $tid ] = $decoded; } } // Cache for 1 minute; used during restore runs. wp_cache_set( $cache_key, $out, $cache_group, MINUTE_IN_SECONDS ); return $out; } /** * Restore a single product from snapshot/backup payloads. * * @param int $product_id Product ID to restore. * @param array|null $snapshot Optional prefetched snapshot payload. * @param array|null $backup Optional prefetched backup payload. * @return bool True if any data was updated. */ public static function restore_product( int $product_id, ?array $snapshot = null, ?array $backup = null ): bool { Logger::write_log( 'info', '[OriginalSnapshot] restore_product start pid=' . $product_id ); $updated = self::restore_parallax_import_backup( $product_id, $backup ); $backup_parallax_meta = self::extract_parallax_meta_from_backup( $backup ); $data = null; if ( null !== $snapshot ) { if ( is_array( $snapshot ) ) { $data = $snapshot; } else { Logger::write_log( 'info', '[OriginalSnapshot] restore_product snapshot payload not array (prefetched) pid=' . $product_id ); } } else { $raw = get_post_meta( $product_id, self::POST_META_KEY, true ); if ( $raw ) { $data = self::decode_snapshot_value( $raw ); if ( ! is_array( $data ) ) { Logger::write_log( 'info', '[OriginalSnapshot] restore_product snapshot payload not array pid=' . $product_id ); $data = null; } } else { Logger::write_log( 'info', '[OriginalSnapshot] restore_product no stored snapshot pid=' . $product_id ); } } if ( is_array( $data ) ) { Logger::write_log( 'info', '[OriginalSnapshot] restore_product applying snapshot pid=' . $product_id ); $res = wp_update_post( array( 'ID' => $product_id, 'post_title' => $data['title'] ?? '', 'post_content' => $data['description'] ?? '', 'post_excerpt' => $data['excerpt'] ?? '', ), true ); if ( ! is_wp_error( $res ) ) { $updated = true; } else { Logger::write_log( 'info', '[OriginalSnapshot] restore_product wp_update_post error pid=' . $product_id . ' msg=' . $res->get_error_message() ); } if ( ! empty( $data['seo'] ) && is_array( $data['seo'] ) ) { $seo = $data['seo']; $w = self::write_seo_meta_for_post( $product_id, $seo['title'] ?? null, $seo['description'] ?? null, $seo['source'] ?? null ); $updated = $updated || $w; } } $snapshot_parallax_meta = is_array( $data ) ? self::extract_parallax_meta_from_snapshot( $data['parallax_meta'] ?? null ) : null; if ( $snapshot_parallax_meta ) { $updated = self::restore_parallax_post_meta( $product_id, $snapshot_parallax_meta ) || $updated; } elseif ( $backup_parallax_meta ) { $updated = self::restore_parallax_post_meta( $product_id, $backup_parallax_meta ) || $updated; } if ( $updated ) { if ( function_exists( 'wp_cache_delete' ) ) { wp_cache_delete( $product_id, 'post_meta' ); wp_cache_delete( $product_id, 'posts' ); } clean_post_cache( $product_id ); } Logger::write_log( 'info', '[OriginalSnapshot] restore_product finished pid=' . $product_id . ' updated=' . ( $updated ? 'yes' : 'no' ) ); return $updated; } /** * Restore a single term from snapshot payloads. * * @param int $term_id Term ID to restore. * @param string $taxonomy Taxonomy of the term. * @param array|null $snapshot Optional prefetched snapshot payload. * @return bool True if any data was updated. */ public static function restore_term( int $term_id, string $taxonomy, ?array $snapshot = null ): bool { Logger::write_log( 'info', '[OriginalSnapshot] restore_term start term_id=' . $term_id . ' taxonomy=' . $taxonomy ); $updated = false; $data = null; if ( null !== $snapshot ) { if ( is_array( $snapshot ) ) { $data = $snapshot; } else { Logger::write_log( 'info', '[OriginalSnapshot] restore_term snapshot payload not array (prefetched) term_id=' . $term_id ); } } else { $raw = get_term_meta( $term_id, self::TERM_META_KEY, true ); if ( $raw ) { $data = self::decode_snapshot_value( $raw ); if ( ! is_array( $data ) ) { Logger::write_log( 'info', '[OriginalSnapshot] restore_term snapshot payload not array term_id=' . $term_id ); $data = null; } } else { Logger::write_log( 'info', '[OriginalSnapshot] restore_term no stored snapshot term_id=' . $term_id ); } } if ( is_array( $data ) ) { $res = wp_update_term( $term_id, $taxonomy, array( 'name' => $data['name'] ?? '', 'description' => $data['description'] ?? '', ) ); if ( ! is_wp_error( $res ) ) { $updated = true; } else { Logger::write_log( 'info', '[OriginalSnapshot] restore_term wp_update_term error term_id=' . $term_id . ' msg=' . $res->get_error_message() ); } if ( ! empty( $data['seo'] ) && is_array( $data['seo'] ) ) { $seo = $data['seo']; $w = self::write_seo_meta_for_term( $term_id, $taxonomy, $seo['title'] ?? null, $seo['description'] ?? null, $seo['source'] ?? null ); $updated = $updated || $w; } $pm = $data['parallax_meta'] ?? null; $updated = self::restore_parallax_term_meta( $term_id, $taxonomy, is_array( $pm ) ? $pm : null ) || $updated; } else { // Even without a stored snapshot, clear Parallax term meta so optimized values are removed. $updated = self::restore_parallax_term_meta( $term_id, $taxonomy, null ) || $updated; } if ( $updated ) { if ( function_exists( 'wp_cache_delete' ) ) { wp_cache_delete( $term_id, 'term_meta' ); wp_cache_delete( 'all_ids', 'terms' ); wp_cache_delete( $term_id, 'terms' ); } clean_term_cache( $term_id, $taxonomy ); } Logger::write_log( 'info', '[OriginalSnapshot] restore_term finished term_id=' . $term_id . ' updated=' . ( $updated ? 'yes' : 'no' ) ); return $updated; } /** * Decode a stored snapshot payload (post or term) into an array. * * @param mixed $raw Raw snapshot payload. * @return array|null */ protected static function decode_snapshot_value( $raw ): ?array { if ( is_array( $raw ) ) { return $raw; } if ( is_string( $raw ) && '' !== $raw ) { $decoded = maybe_unserialize( $raw ); return is_array( $decoded ) ? $decoded : null; } return null; } /** * Decode stored _parallax_prev_payload meta into an array. * * @param mixed $raw Raw backup payload. * @return array */ protected static function decode_prev_payload_value( $raw ): array { if ( is_array( $raw ) ) { return $raw; } if ( is_string( $raw ) && '' !== $raw ) { $decoded = json_decode( $raw, true ); if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) { return $decoded; } $maybe = maybe_unserialize( $raw ); if ( is_array( $maybe ) ) { return $maybe; } } return array(); } /** * Restore Parallax-specific fields from backup metadata. * * @param int $product_id Product ID being restored. * @param array|null $prefetched_backup Optional prefetched backup payload. * @return bool True if any fields were written. */ protected static function restore_parallax_import_backup( int $product_id, ?array $prefetched_backup = null ): bool { $backup = $prefetched_backup; if ( null === $backup ) { $backup = self::get_parallax_prev_payload( $product_id ); } $wrote = false; if ( $backup ) { Logger::write_log( 'info', '[OriginalSnapshot] restore_parallax_import_backup backup found pid=' . $product_id ); if ( array_key_exists( '_parallax_meta_title', $backup ) ) { $ret = update_post_meta( $product_id, '_parallax_meta_title', wp_kses_post( (string) $backup['_parallax_meta_title'] ) ); $wrote = $wrote || (bool) $ret; } if ( array_key_exists( '_parallax_meta_desc', $backup ) ) { $ret = update_post_meta( $product_id, '_parallax_meta_desc', wp_kses_post( (string) $backup['_parallax_meta_desc'] ) ); $wrote = $wrote || (bool) $ret; } if ( array_key_exists( 'yoast_title', $backup ) ) { $yoast_title = (string) $backup['yoast_title']; if ( '' === $yoast_title ) { $deleted = delete_post_meta( $product_id, '_yoast_wpseo_title' ); $wrote = $wrote || (bool) $deleted; } else { $ret = update_post_meta( $product_id, '_yoast_wpseo_title', $yoast_title ); $wrote = $wrote || (bool) $ret; } } if ( array_key_exists( 'yoast_desc', $backup ) ) { $yoast_desc = (string) $backup['yoast_desc']; if ( '' === $yoast_desc ) { $deleted = delete_post_meta( $product_id, '_yoast_wpseo_metadesc' ); $wrote = $wrote || (bool) $deleted; } else { $ret = update_post_meta( $product_id, '_yoast_wpseo_metadesc', $yoast_desc ); $wrote = $wrote || (bool) $ret; } } if ( array_key_exists( 'short_description', $backup ) ) { $desc = (string) $backup['short_description']; $res = wp_update_post( array( 'ID' => $product_id, 'post_excerpt' => Sanitize::html_to_plain_text( $desc ), ), true ); if ( ! is_wp_error( $res ) ) { $wrote = true; } } if ( array_key_exists( 'description', $backup ) ) { $body = (string) $backup['description']; $res = wp_update_post( array( 'ID' => $product_id, 'post_content' => wp_kses_post( $body ), ), true ); if ( ! is_wp_error( $res ) ) { $wrote = true; } } if ( array_key_exists( 'name', $backup ) && '' !== $backup['name'] ) { $res = wp_update_post( array( 'ID' => $product_id, 'post_title' => wp_kses_post( (string) $backup['name'] ), ), true ); if ( ! is_wp_error( $res ) ) { $wrote = true; } } } else { Logger::write_log( 'info', '[OriginalSnapshot] restore_parallax_import_backup no backup payload pid=' . $product_id ); } $wrote = self::clear_parallax_import_markers( $product_id ) || $wrote; if ( $wrote ) { Logger::write_log( 'info', '[OriginalSnapshot] restore_parallax_import_backup wrote changes pid=' . $product_id ); } return $wrote; } /** * Fetch and decode the Parallax backup payload for a product. * * @param int $product_id Product ID. * @return array */ protected static function get_parallax_prev_payload( int $product_id ): array { $raw = get_post_meta( $product_id, self::PREV_PAYLOAD_META_KEY, true ); $decoded = self::decode_prev_payload_value( $raw ); if ( ! $decoded ) { Logger::write_log( 'info', '[OriginalSnapshot] get_parallax_prev_payload empty pid=' . $product_id ); } return $decoded; } /** * Clear import markers/meta on a product after restoration. * * @param int $product_id Product ID. * @return bool True if any markers were cleared/updated. */ protected static function clear_parallax_import_markers( int $product_id ): bool { $changed = false; $status = (string) get_post_meta( $product_id, '_parallax_import_status', true ); if ( 'original' !== $status ) { $updated = update_post_meta( $product_id, '_parallax_import_status', 'original' ); $changed = $changed || (bool) $updated; } if ( delete_post_meta( $product_id, self::PREV_PAYLOAD_META_KEY ) ) { $changed = true; } return $changed; } /** * Restore Parallax meta fields for a product. * * @param int $product_id Product ID. * @param array|null $parallax_meta Normalized meta payload. * @return bool True if any meta values were written/removed. */ protected static function restore_parallax_post_meta( int $product_id, ?array $parallax_meta ): bool { if ( ! is_array( $parallax_meta ) ) { return false; } $title = array_key_exists( 'title', $parallax_meta ) ? self::normalize_parallax_meta_string( $parallax_meta['title'] ) : null; $desc = array_key_exists( 'description', $parallax_meta ) ? self::normalize_parallax_meta_string( $parallax_meta['description'] ) : null; $wrote = false; if ( null !== $title ) { $ret = update_post_meta( $product_id, '_parallax_meta_title', wp_kses_post( (string) $title ) ); $wrote = $wrote || (bool) $ret; } else { $deleted = delete_post_meta( $product_id, '_parallax_meta_title' ); $wrote = $wrote || (bool) $deleted; } if ( null !== $desc ) { $ret = update_post_meta( $product_id, '_parallax_meta_desc', wp_kses_post( (string) $desc ) ); $wrote = $wrote || (bool) $ret; } else { $deleted = delete_post_meta( $product_id, '_parallax_meta_desc' ); $wrote = $wrote || (bool) $deleted; } if ( $wrote ) { Logger::write_log( 'info', '[OriginalSnapshot] restore_parallax_post_meta updated pid=' . $product_id ); } return $wrote; } /** * Read Parallax meta fields for a product. * * @param int $product_id Product ID. * @return array{title:?string,description:?string} */ protected static function get_parallax_meta_for_product( int $product_id ): array { $title = get_post_meta( $product_id, '_parallax_meta_title', true ); $desc = get_post_meta( $product_id, '_parallax_meta_desc', true ); return array( 'title' => ( '' !== $title ) ? (string) $title : null, 'description' => ( '' !== $desc ) ? (string) $desc : null, ); } /** * Extract normalized Parallax meta from a snapshot payload. * * @param mixed $maybe_meta Raw snapshot meta portion. * @return array{title:?string,description:?string}|null */ protected static function extract_parallax_meta_from_snapshot( $maybe_meta ): ?array { if ( ! is_array( $maybe_meta ) ) { return null; } $title = array_key_exists( 'title', $maybe_meta ) ? self::normalize_parallax_meta_string( $maybe_meta['title'] ) : null; $desc = array_key_exists( 'description', $maybe_meta ) ? self::normalize_parallax_meta_string( $maybe_meta['description'] ) : null; return array( 'title' => $title, 'description' => $desc, ); } /** * Extract normalized Parallax meta from a backup payload. * * @param array|null $backup Backup payload. * @return array{title:?string,description:?string}|null */ protected static function extract_parallax_meta_from_backup( ?array $backup ): ?array { if ( ! is_array( $backup ) ) { return null; } $title = self::first_available_parallax_meta_value( $backup, array( '_parallax_meta_title', 'meta_title', ) ); $desc = self::first_available_parallax_meta_value( $backup, array( '_parallax_meta_desc', 'meta_description', 'meta_desc', ) ); return array( 'title' => self::normalize_parallax_meta_string( $title ), 'description' => self::normalize_parallax_meta_string( $desc ), ); } /** * Return the first available value from a set of keys. * * @param array $source Source array to inspect. * @param array $keys Keys to check in order. * @return mixed|null */ protected static function first_available_parallax_meta_value( array $source, array $keys ) { foreach ( $keys as $key ) { if ( array_key_exists( $key, $source ) ) { return $source[ $key ]; } } return null; } /** * Normalize various value types into a string for Parallax meta. * * @param mixed $value Value to normalize. * @return string|null */ protected static function normalize_parallax_meta_string( $value ): ?string { if ( null === $value ) { return null; } if ( is_string( $value ) || is_numeric( $value ) ) { return (string) $value; } if ( is_bool( $value ) ) { return $value ? '1' : '0'; } return null; } /** * Restore Parallax meta fields for a term. * * @param int $term_id Term ID. * @param string $taxonomy Term taxonomy. * @param array|null $parallax_meta Normalized meta payload. * @return bool True if any meta values were written/removed. */ protected static function restore_parallax_term_meta( int $term_id, string $taxonomy, ?array $parallax_meta ): bool { $title = $parallax_meta['title'] ?? null; $desc = $parallax_meta['description'] ?? null; $wrote = false; if ( null !== $title ) { $ret = update_term_meta( $term_id, '_parallax_meta_title', wp_kses_post( (string) $title ) ); $wrote = $wrote || (bool) $ret; } else { $deleted = delete_term_meta( $term_id, '_parallax_meta_title' ); $wrote = $wrote || (bool) $deleted; } if ( null !== $desc ) { $ret = update_term_meta( $term_id, '_parallax_meta_desc', wp_kses_post( (string) $desc ) ); $wrote = $wrote || (bool) $ret; } else { $deleted = delete_term_meta( $term_id, '_parallax_meta_desc' ); $wrote = $wrote || (bool) $deleted; } if ( $wrote ) { Logger::write_log( 'info', '[OriginalSnapshot] restore_parallax_term_meta updated term_id=' . $term_id . ' taxonomy=' . $taxonomy ); } return $wrote; } /** * Read Parallax meta fields for a term. * * @param int $term_id Term ID. * @return array{title:?string,description:?string} */ protected static function get_parallax_meta_for_term( int $term_id ): array { $title = get_term_meta( $term_id, '_parallax_meta_title', true ); $desc = get_term_meta( $term_id, '_parallax_meta_desc', true ); return array( 'title' => ( '' !== $title ) ? (string) $title : null, 'description' => ( '' !== $desc ) ? (string) $desc : null, ); } /** * Fetch SEO metadata for a post from supported providers. * * @param int $post_id Post ID. * @return array{title:?string,description:?string,source:string} */ public static function get_seo_meta_for_post( int $post_id ): array { $yoast_title = get_post_meta( $post_id, '_yoast_wpseo_title', true ); $yoast_desc = get_post_meta( $post_id, '_yoast_wpseo_metadesc', true ); if ( $yoast_title || $yoast_desc ) { return array( 'title' => $yoast_title ? $yoast_title : null, 'description' => $yoast_desc ? $yoast_desc : null, 'source' => 'yoast', ); } $rm_title = get_post_meta( $post_id, 'rank_math_title', true ); $rm_desc = get_post_meta( $post_id, 'rank_math_description', true ); if ( $rm_title || $rm_desc ) { return array( 'title' => $rm_title ? $rm_title : null, 'description' => $rm_desc ? $rm_desc : null, 'source' => 'rank-math', ); } $sp_title = get_post_meta( $post_id, '_seopress_titles_title', true ); $sp_desc = get_post_meta( $post_id, '_seopress_titles_desc', true ); if ( $sp_title || $sp_desc ) { return array( 'title' => $sp_title ? $sp_title : null, 'description' => $sp_desc ? $sp_desc : null, 'source' => 'seopress', ); } if ( defined( 'AIOSEO_VERSION' ) ) { global $wpdb; $table_raw = $wpdb->prefix . 'aioseo_posts'; // Only query if the AIOSEO table exists. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Table existence check is required; cached below for reads. $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_raw ) ); if ( $table_exists === $table_raw ) { $cache_group = 'ai_content_by_parallax'; $cache_key = 'aioseo_post_meta:' . (string) $post_id; $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached && is_array( $cached ) ) { return $cached; } // Build a SQL-safe identifier for the table name (identifiers cannot be placeholders). $table_sql = '`' . str_replace( '`', '', esc_sql( $table_raw ) ) . '`'; /* * Table name is a known plugin-owned identifier and cannot be parameterized. * The post_id value is safely prepared. */ $sql = 'SELECT title, description FROM ' . $table_sql . ' WHERE post_id = %d LIMIT 1'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Direct read required; cached; identifier is internal + escaped/backticked; PluginCheck false positive. $row = $wpdb->get_row( $wpdb->prepare( $sql, $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Identifier cannot be parameterized; value is prepared. ); if ( $row && ( '' !== $row->title || '' !== $row->description ) ) { $out = array( 'title' => '' !== $row->title ? $row->title : null, 'description' => '' !== $row->description ? $row->description : null, 'source' => 'aioseo', ); wp_cache_set( $cache_key, $out, $cache_group, MINUTE_IN_SECONDS ); return $out; } // Cache the miss briefly too. wp_cache_set( $cache_key, array( 'title' => null, 'description' => null, 'source' => 'none', ), $cache_group, 30 ); } } return array( 'title' => null, 'description' => null, 'source' => 'none', ); } /** * Fetch SEO metadata for a term from supported providers. * * @param int $term_id Term ID. * @param string $taxonomy Term taxonomy. * @return array{title:?string,description:?string,source:string} */ public static function get_seo_meta_for_term( int $term_id, string $taxonomy ): array { $rm_title = get_term_meta( $term_id, 'rank_math_title', true ); $rm_desc = get_term_meta( $term_id, 'rank_math_description', true ); if ( '' !== $rm_title || '' !== $rm_desc ) { return array( 'title' => ( '' !== $rm_title ) ? $rm_title : null, 'description' => ( '' !== $rm_desc ) ? $rm_desc : null, 'source' => 'rank-math', ); } $sp_title = get_term_meta( $term_id, '_seopress_titles_title', true ); $sp_desc = get_term_meta( $term_id, '_seopress_titles_desc', true ); if ( '' !== $sp_title || '' !== $sp_desc ) { return array( 'title' => ( '' !== $sp_title ) ? $sp_title : null, 'description' => ( '' !== $sp_desc ) ? $sp_desc : null, 'source' => 'seopress', ); } $yoast_meta = YoastBridge::get_yoast_term_meta_for_term( $term_id, $taxonomy ); $yoast_title = $yoast_meta['wpseo_title'] ?? ''; $yoast_desc = $yoast_meta['wpseo_desc'] ?? ''; if ( '' !== $yoast_title || '' !== $yoast_desc ) { return array( 'title' => ( '' !== $yoast_title ) ? $yoast_title : null, 'description' => ( '' !== $yoast_desc ) ? $yoast_desc : null, 'source' => 'yoast', ); } if ( defined( 'AIOSEO_VERSION' ) ) { global $wpdb; $table_raw = $wpdb->prefix . 'aioseo_terms'; // Cache the existence check to avoid repeated schema queries. $exists_cache_group = 'ai_content_by_parallax'; $exists_cache_key = 'aioseo_terms_table_exists:' . md5( $wpdb->dbname . '|' . $table_raw ); $cached_exists = wp_cache_get( $exists_cache_key, $exists_cache_group ); if ( false === $cached_exists ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Schema check; cached via wp_cache_*. $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_raw ) ); $cached_exists = ( $table_exists === $table_raw ) ? 1 : 0; wp_cache_set( $exists_cache_key, $cached_exists, $exists_cache_group, HOUR_IN_SECONDS ); } if ( 1 === (int) $cached_exists ) { $cache_group = 'ai_content_by_parallax'; $cache_key = 'aioseo_term_meta:' . $taxonomy . ':' . (string) $term_id; $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached && is_array( $cached ) ) { return $cached; } // SQL-safe identifier for manual SQL; identifiers cannot be placeholders in prepare(). $table_sql = '`' . str_replace( '`', '', esc_sql( $table_raw ) ) . '`'; $sql = 'SELECT title, description FROM ' . $table_sql . ' WHERE term_id = %d LIMIT 1'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Direct read required; cached; identifier is internal + escaped/backticked; PluginCheck false positive. $row = $wpdb->get_row( $wpdb->prepare( $sql, $term_id ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Identifier cannot be parameterized; value is prepared. ); if ( $row && ( '' !== $row->title || '' !== $row->description ) ) { $result = array( 'title' => ( '' !== $row->title ) ? $row->title : null, 'description' => ( '' !== $row->description ) ? $row->description : null, 'source' => 'aioseo', ); wp_cache_set( $cache_key, $result, $cache_group, MINUTE_IN_SECONDS ); return $result; } // Cache the miss briefly too. wp_cache_set( $cache_key, array( 'title' => null, 'description' => null, 'source' => 'none', ), $cache_group, 30 ); } } return array( 'title' => null, 'description' => null, 'source' => 'none', ); } /** * Cached check for whether a given table exists. * * @param string $table_raw Raw table name (internal). * @return bool */ protected static function table_exists_cached( string $table_raw ): bool { global $wpdb; $cache_group = 'ai_content_by_parallax'; $cache_key = 'table_exists:' . md5( $wpdb->dbname . '|' . $table_raw ); $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached ) { return (bool) $cached; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Table existence check; cached via wp_cache_*. $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_raw ) ); $exists = ( $found === $table_raw ); // Cache for 1 day; table existence rarely changes. wp_cache_set( $cache_key, $exists ? 1 : 0, $cache_group, DAY_IN_SECONDS ); return $exists; } /** * Write SEO metadata for a post based on detected/desired provider. * * @param int $post_id Post ID. * @param string|null $title SEO title. * @param string|null $description SEO description. * @param string|null $source Optional preferred provider. * @return bool True if any metadata was written. */ public static function write_seo_meta_for_post( int $post_id, ?string $title, ?string $description, ?string $source = null ): bool { $w = false; $prefer = $source; if ( null === $prefer ) { $prefer = self::detect_active_seo_post_source( $post_id ); } // Yoast. if ( 'yoast' === $prefer || ( null === $prefer && self::meta_key_exists_for_post( $post_id, '_yoast_wpseo_title' ) ) ) { $w = (bool) update_post_meta( $post_id, '_yoast_wpseo_title', (string) ( null !== $title ? $title : '' ) ) || $w; $w = (bool) update_post_meta( $post_id, '_yoast_wpseo_metadesc', (string) ( null !== $description ? $description : '' ) ) || $w; return $w; } // Rank Math. if ( 'rank-math' === $prefer || ( null === $prefer && self::meta_key_exists_for_post( $post_id, 'rank_math_title' ) ) ) { $w = (bool) update_post_meta( $post_id, 'rank_math_title', (string) ( null !== $title ? $title : '' ) ) || $w; $w = (bool) update_post_meta( $post_id, 'rank_math_description', (string) ( null !== $description ? $description : '' ) ) || $w; return $w; } // SEOPress. if ( 'seopress' === $prefer || ( null === $prefer && self::meta_key_exists_for_post( $post_id, '_seopress_titles_title' ) ) ) { $w = (bool) update_post_meta( $post_id, '_seopress_titles_title', (string) ( null !== $title ? $title : '' ) ) || $w; $w = (bool) update_post_meta( $post_id, '_seopress_titles_desc', (string) ( null !== $description ? $description : '' ) ) || $w; return $w; } // AIOSEO. if ( 'aioseo' === $prefer || ( defined( 'AIOSEO_VERSION' ) && null === $prefer ) ) { global $wpdb; $table_raw = $wpdb->prefix . 'aioseo_posts'; // Only proceed if the AIOSEO table exists (cached). if ( self::table_exists_cached( $table_raw ) ) { $data = array( 'title' => (string) ( null !== $title ? $title : '' ), 'description' => (string) ( null !== $description ? $description : '' ), ); $where = array( 'post_id' => $post_id, ); /* * $wpdb->update() is the recommended way to perform updates with proper escaping. * Table name is internal (plugin-owned) and cannot be parameterized. */ $table_safe = esc_sql( $table_raw ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct write required; caching not applicable for writes. // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier is internal + sanitized via esc_sql(). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Required for efficient backfill scan; results cached via wp_cache_*. $updated = $wpdb->update( $table_safe, $data, $where ); if ( false !== $updated ) { // Invalidate any cached read we may have set. wp_cache_delete( 'aioseo_post_meta:' . (string) $post_id, 'ai_content_by_parallax' ); return true; } return false; } } return $w; } /** * Write SEO metadata for a term based on detected/desired provider. * * @param int $term_id Term ID. * @param string $taxonomy Term taxonomy. * @param string|null $title SEO title. * @param string|null $description SEO description. * @param string|null $source Optional preferred provider. * @return bool True if any metadata was written. */ public static function write_seo_meta_for_term( int $term_id, string $taxonomy, ?string $title, ?string $description, ?string $source = null ): bool { $w = false; $prefer = $source ? $source : self::detect_active_seo_terms_source( $term_id, $taxonomy ); if ( 'rank-math' === $prefer || ( null === $prefer && get_term_meta( $term_id, 'rank_math_title', true ) !== '' ) ) { $w = update_term_meta( $term_id, 'rank_math_title', (string) ( $title ?? '' ) ) || $w; $w = update_term_meta( $term_id, 'rank_math_description', (string) ( $description ?? '' ) ) || $w; return $w; } if ( 'seopress' === $prefer || ( null === $prefer && get_term_meta( $term_id, '_seopress_titles_title', true ) !== '' ) ) { $w = update_term_meta( $term_id, '_seopress_titles_title', (string) ( $title ?? '' ) ) || $w; $w = update_term_meta( $term_id, '_seopress_titles_desc', (string) ( $description ?? '' ) ) || $w; return $w; } if ( 'yoast' === $prefer || null === $prefer ) { if ( YoastBridge::is_term_sync_enabled() ) { YoastBridge::update_wpseo_taxonomy_meta_for_term( $term_id, $taxonomy, array( 'wpseo_title' => (string) ( $title ?? '' ), 'wpseo_desc' => (string) ( $description ?? '' ), ) ); return true; } if ( 'yoast' === $prefer ) { return false; } } if ( 'aioseo' === $prefer || ( defined( 'AIOSEO_VERSION' ) && null === $prefer ) ) { global $wpdb; $table_raw = $wpdb->prefix . 'aioseo_terms'; // Only proceed if the AIOSEO table exists (cached). if ( self::table_exists_cached( $table_raw ) ) { $table_safe = esc_sql( $table_raw ); /* * Direct write required; table identifier is internal (plugin-owned) and cannot be parameterized. */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct write required; caching not applicable for writes. // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier is internal + sanitized via esc_sql(). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct write required to third-party plugin table; caching not applicable for writes. $updated = $wpdb->update( $table_safe, array( 'title' => (string) ( $title ?? '' ), 'description' => (string) ( $description ?? '' ), ), array( 'term_id' => $term_id ), array( '%s', '%s' ), array( '%d' ) ); if ( false !== $updated ) { // Invalidate cached reads from get_seo_meta_for_term(). wp_cache_delete( 'aioseo_term_meta:' . $taxonomy . ':' . (string) $term_id, 'ai_content_by_parallax' ); return true; } return false; } } return $w; } /** * Detect which SEO provider is active for a post. * * @param int $post_id Post ID. * @return string|null Provider slug or null if none detected. */ protected static function detect_active_seo_post_source( int $post_id ): ?string { if ( get_post_meta( $post_id, '_yoast_wpseo_title', true ) !== '' ) { return 'yoast'; } if ( get_post_meta( $post_id, 'rank_math_title', true ) !== '' ) { return 'rank-math'; } if ( get_post_meta( $post_id, '_seopress_titles_title', true ) !== '' ) { return 'seopress'; } if ( defined( 'AIOSEO_VERSION' ) ) { return 'aioseo'; } return null; } /** * Detect which SEO provider is active for a term. * * @param int $term_id Term ID. * @param string $taxonomy Taxonomy of the term. * @return string|null Provider slug or null if none detected. */ protected static function detect_active_seo_terms_source( int $term_id, string $taxonomy ): ?string { unset( $taxonomy ); if ( get_term_meta( $term_id, 'rank_math_title', true ) !== '' ) { return 'rank-math'; } if ( get_term_meta( $term_id, '_seopress_titles_title', true ) !== '' ) { return 'seopress'; } if ( YoastBridge::is_term_sync_enabled() && ! empty( YoastBridge::get_yoast_term_meta_for_term( $term_id, $taxonomy ) ) ) { return 'yoast'; } if ( defined( 'AIOSEO_VERSION' ) ) { return 'aioseo'; } return null; } /** * Check if a post meta key exists and has a non-empty value. * * @param int $post_id Post ID. * @param string $key Meta key to inspect. * @return bool */ protected static function meta_key_exists_for_post( int $post_id, string $key ): bool { $v = get_post_meta( $post_id, $key, true ); return ( '' !== $v && null !== $v ); } }