*/ private const TERM_META_ALLOWED_FIELDS = array( 'wpseo_title', 'wpseo_desc' ); private const POST_META_MAP = array( SeoMetaFields::META_TITLE_KEY => '_yoast_wpseo_title', SeoMetaFields::META_DESC_KEY => '_yoast_wpseo_metadesc', ); private const TERM_META_MAP = array( SeoMetaFields::TERM_META_TITLE_KEY => 'wpseo_title', SeoMetaFields::TERM_META_DESC_KEY => 'wpseo_desc', ); /** * Flag to prevent recursion during post meta sync operations. * * @var boolean */ private static $syncing_post_meta = false; /** * Register hooks that sync Parallax metadata into Yoast. */ public static function register(): void { if ( ! self::is_active() ) { return; } add_filter( 'get_post_metadata', array( self::class, 'filter_get_post_meta' ), 10, 4 ); add_filter( 'get_term_metadata', array( self::class, 'filter_get_term_meta' ), 10, 4 ); add_action( 'added_post_meta', array( self::class, 'handle_added_post_meta' ), 10, 4 ); add_action( 'updated_post_meta', array( self::class, 'handle_updated_post_meta' ), 10, 4 ); add_action( 'deleted_post_meta', array( self::class, 'handle_deleted_post_meta' ), 10, 3 ); add_action( 'added_term_meta', array( self::class, 'handle_added_term_meta' ), 10, 4 ); add_action( 'updated_term_meta', array( self::class, 'handle_updated_term_meta' ), 10, 4 ); add_action( 'deleted_term_meta', array( self::class, 'handle_deleted_term_meta' ), 10, 3 ); } /** * Check whether Yoast SEO is active. * * @return bool */ private static function is_active(): bool { return defined( 'WPSEO_VERSION' ) || class_exists( 'WPSEO_Taxonomy_Meta' ) || class_exists( 'WPSEO_Meta' ); } /** * Check whether Yoast integration is enabled. * * @return bool */ public static function is_enabled(): bool { return self::is_active(); } /** * Check whether term sync to Yoast is enabled by settings. * * @return bool */ public static function is_term_sync_enabled(): bool { return self::is_active() && ! (bool) Settings::get( Settings::DISABLE_YOAST_TERM_SYNC_KEY, false ); } /** * Swap Parallax post meta reads with Yoast values when they exist. * * @param mixed $value Default value. * @param int $post_id Post ID. * @param string $meta_key Meta key requested. * @param bool $single Whether a single value is requested. * @return mixed */ public static function filter_get_post_meta( $value, $post_id, $meta_key, $single ) { $yoast_key = self::POST_META_MAP[ $meta_key ] ?? null; if ( ! $yoast_key ) { return $value; } if ( ! metadata_exists( 'post', (int) $post_id, $yoast_key ) ) { return $value; } $yoast_value = self::get_post_meta_raw( (int) $post_id, $yoast_key, (bool) $single ); return null === $yoast_value ? $value : $yoast_value; } /** * Use Yoast taxonomy option data when Parallax term meta is read. * * @param mixed $value Default value. * @param int $term_id Term ID. * @param string $meta_key Meta key requested. * @param bool $single Whether a single value is requested. * @return mixed */ public static function filter_get_term_meta( $value, $term_id, $meta_key, $single ) { $field = self::TERM_META_MAP[ $meta_key ] ?? null; if ( ! $field ) { return $value; } $taxonomy = self::get_term_taxonomy( (int) $term_id ); if ( ! $taxonomy ) { return $value; } $yoast_value = self::get_yoast_term_field( $taxonomy, (int) $term_id, $field ); if ( null === $yoast_value ) { return $value; } return $single ? $yoast_value : array( $yoast_value ); } /** * Mirror Parallax post meta additions into Yoast. * * @param int $meta_id Meta row ID. * @param int $post_id Post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. */ public static function handle_added_post_meta( $meta_id, $post_id, $meta_key, $meta_value ): void { self::mirror_post_meta( (int) $post_id, (string) $meta_key, $meta_value ); } /** * Mirror Parallax post meta updates into Yoast. * * @param int $meta_id Meta row ID. * @param int $post_id Post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. */ public static function handle_updated_post_meta( $meta_id, $post_id, $meta_key, $meta_value ): void { self::mirror_post_meta( (int) $post_id, (string) $meta_key, $meta_value ); } /** * Remove mirrored Yoast meta when Parallax post meta is deleted. * * @param int $meta_ids Meta row IDs. * @param int $post_id Post ID. * @param string $meta_key Meta key. */ public static function handle_deleted_post_meta( $meta_ids, $post_id, $meta_key ): void { $yoast_key = self::POST_META_MAP[ $meta_key ] ?? null; if ( ! $yoast_key || self::$syncing_post_meta ) { return; } self::$syncing_post_meta = true; delete_post_meta( (int) $post_id, $yoast_key ); self::$syncing_post_meta = false; } /** * Mirror Parallax post metadata into Yoast fields. * * @param int $post_id Post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. */ private static function mirror_post_meta( int $post_id, string $meta_key, $meta_value ): void { $yoast_key = self::POST_META_MAP[ $meta_key ] ?? null; if ( ! $yoast_key || self::$syncing_post_meta ) { return; } self::$syncing_post_meta = true; $normalized_value = self::normalize_scalar( $meta_value ); if ( '' === $normalized_value ) { delete_post_meta( $post_id, $yoast_key ); } else { update_post_meta( $post_id, $yoast_key, $normalized_value ); } self::$syncing_post_meta = false; } /** * Mirror Parallax term meta additions into Yoast. * * @param int $meta_id Meta row ID. * @param int $term_id Term ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. */ public static function handle_added_term_meta( $meta_id, $term_id, $meta_key, $meta_value ): void { self::mirror_term_meta( (int) $term_id, (string) $meta_key, $meta_value ); } /** * Mirror Parallax term meta updates into Yoast. * * @param int $meta_id Meta row ID. * @param int $term_id Term ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. */ public static function handle_updated_term_meta( $meta_id, $term_id, $meta_key, $meta_value ): void { self::mirror_term_meta( (int) $term_id, (string) $meta_key, $meta_value ); } /** * Remove mirrored Yoast term meta when Parallax meta is deleted. * * @param int $meta_ids Meta row IDs. * @param int $term_id Term ID. * @param string $meta_key Meta key. */ public static function handle_deleted_term_meta( $meta_ids, $term_id, $meta_key ): void { $field = self::TERM_META_MAP[ $meta_key ] ?? null; if ( ! $field ) { return; } $taxonomy = self::get_term_taxonomy( (int) $term_id ); if ( ! $taxonomy ) { return; } self::delete_yoast_term_field( $taxonomy, (int) $term_id, $field ); } /** * Mirror Parallax term metadata into Yoast fields. * * @param int $term_id Term ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. */ private static function mirror_term_meta( int $term_id, string $meta_key, $meta_value ): void { $field = self::TERM_META_MAP[ $meta_key ] ?? null; if ( ! $field ) { return; } $taxonomy = self::get_term_taxonomy( $term_id ); if ( ! $taxonomy ) { return; } $normalized_value = self::normalize_scalar( $meta_value ); if ( '' === $normalized_value ) { self::delete_yoast_term_field( $taxonomy, $term_id, $field ); } else { self::set_yoast_term_field( $taxonomy, $term_id, $field, $normalized_value ); } } /** * Normalize values to strings for meta storage. * * @param mixed $value Value to normalize. * @return string */ private static function normalize_scalar( $value ): string { if ( is_scalar( $value ) || ( is_object( $value ) && method_exists( $value, '__toString' ) ) ) { return trim( (string) $value ); } return ''; } /** * Get the taxonomy for a term. * * @param int $term_id Term ID. * @return string|null */ private static function get_term_taxonomy( int $term_id ): ?string { $term = get_term( $term_id ); if ( $term instanceof \WP_Term ) { return (string) $term->taxonomy; } return null; } /** * Get raw post meta values without filters, with caching. * * @param int $post_id Post ID. * @param string $meta_key Meta key. * @param bool $single Whether to return a single value. * @return mixed */ private static function get_post_meta_raw( int $post_id, string $meta_key, bool $single ) { // Prefer Core API when available (WP 6.3+). if ( function_exists( 'get_metadata_raw' ) ) { return get_metadata_raw( 'post', $post_id, $meta_key, $single ); } $post_id = (int) $post_id; $meta_key = (string) $meta_key; $single = (bool) $single; $cache_key = sprintf( 'post_meta_raw:%d:%s:%d', $post_id, $meta_key, (int) $single ); $cache_group = 'ai_content_by_parallax'; $cached = wp_cache_get( $cache_key, $cache_group ); if ( false !== $cached ) { return $cached; } global $wpdb; /* * Table names cannot be passed via placeholders. * $wpdb->postmeta is an internal, trusted identifier. */ $table = $wpdb->postmeta; if ( true === $single ) { // Static query prevents "unsafe SQL assigned" warnings. $sql = "SELECT meta_value FROM {$table} WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $result = $wpdb->get_var( $wpdb->prepare( $sql, $post_id, $meta_key ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepare() used correctly. ); } else { // Static query prevents "unsafe SQL assigned" warnings. $sql = "SELECT meta_value FROM {$table} WHERE post_id = %d AND meta_key = %s ORDER BY meta_id ASC"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $result = $wpdb->get_col( $wpdb->prepare( $sql, $post_id, $meta_key ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepare() used correctly. ); } wp_cache_set( $cache_key, $result, $cache_group ); return $result; } /** * Get the Yoast taxonomy meta option array (read-only fallback). * * @return array */ public static function get_wpseo_taxonomy_meta(): array { $option = get_option( 'wpseo_taxonomy_meta', array() ); return is_array( $option ) ? $option : array(); } /** * Read Yoast term meta for a specific term. * * Prefers Yoast's public API when available; falls back to a read-only * option access so that Plugin Check does not flag shared option writes. * * @param int $term_id Term ID. * @param string $taxonomy Taxonomy slug. * @return array Allowed field/value pairs. */ public static function get_yoast_term_meta_for_term( int $term_id, string $taxonomy ): array { $term_id = absint( $term_id ); $taxonomy = sanitize_key( $taxonomy ); if ( 0 === $term_id || '' === $taxonomy ) { return array(); } $data = self::get_yoast_term_meta_via_api( $taxonomy, $term_id ); if ( null === $data ) { $option = self::get_wpseo_taxonomy_meta(); $data = isset( $option[ $taxonomy ][ $term_id ] ) && is_array( $option[ $taxonomy ][ $term_id ] ) ? $option[ $taxonomy ][ $term_id ] : array(); } $result = array(); foreach ( self::TERM_META_ALLOWED_FIELDS as $field ) { if ( isset( $data[ $field ] ) && is_string( $data[ $field ] ) ) { $result[ $field ] = $data[ $field ]; } } return $result; } /** * Update specific Yoast term fields via the Yoast API (no direct option write by default). * * This sync is optional (user setting) and intentionally scoped to the two * Yoast fields we mirror for compatibility: wpseo_title and wpseo_desc. * * Direct writes to wpseo_taxonomy_meta are avoided to comply with WordPress.org * review guidance. Yoast's public API is used when available; otherwise the * update is skipped. * * @param int $term_id Term ID. * @param string $taxonomy Taxonomy slug. * @param array $changes Field/value pairs to update (null removes). */ public static function update_wpseo_taxonomy_meta_for_term( int $term_id, string $taxonomy, array $changes ): void { try { if ( ! self::is_term_sync_enabled() ) { self::log_debug( 'Term sync disabled; skipping Yoast taxonomy meta write.', array( 'term_id' => $term_id, 'taxonomy' => $taxonomy, ) ); return; } $term_id = absint( $term_id ); $taxonomy = sanitize_key( $taxonomy ); if ( 0 === $term_id || '' === $taxonomy || empty( $changes ) ) { return; } if ( ! self::is_allowed_taxonomy( $taxonomy ) ) { self::log_debug( 'Yoast term sync skipped for unsupported taxonomy.', array( 'term_id' => $term_id, 'taxonomy' => $taxonomy, ) ); return; } $changes = self::normalize_term_field_changes( $changes ); if ( empty( $changes ) ) { return; } // Prefer Yoast's taxonomy meta API when available. if ( self::update_yoast_term_meta_via_api( $term_id, $taxonomy, $changes ) ) { return; } self::log_debug( 'Yoast API unavailable; skipping taxonomy meta write to avoid shared option updates.', array( 'term_id' => $term_id, 'taxonomy' => $taxonomy, ) ); } catch ( \Throwable $error ) { self::log_debug( 'Yoast taxonomy meta sync failed; continuing without fatal error.', array( 'term_id' => $term_id, 'taxonomy' => $taxonomy, 'error' => $error->getMessage(), ) ); } } /** * Normalize allowed Yoast term changes to sanitized strings/nulls. * * @param array $changes Raw changes. * @return array */ private static function normalize_term_field_changes( array $changes ): array { $normalized = array(); foreach ( $changes as $field => $value ) { if ( ! in_array( $field, self::TERM_META_ALLOWED_FIELDS, true ) ) { continue; } if ( null === $value ) { $normalized[ $field ] = null; continue; } if ( ! is_scalar( $value ) && ! ( is_object( $value ) && method_exists( $value, '__toString' ) ) ) { continue; } $normalized[ $field ] = sanitize_text_field( (string) $value ); } return $normalized; } /** * Determine which taxonomies are eligible for Yoast term sync. * * @return array */ private static function get_allowed_taxonomies(): array { $taxonomies = array( 'product_cat', 'product_tag' ); if ( function_exists( 'wc_get_attribute_taxonomies' ) ) { $attributes = wc_get_attribute_taxonomies(); if ( ! empty( $attributes ) ) { foreach ( $attributes as $attribute ) { if ( ! empty( $attribute->attribute_name ) && function_exists( 'wc_attribute_taxonomy_name' ) ) { $taxonomies[] = wc_attribute_taxonomy_name( $attribute->attribute_name ); } } } } $taxonomies = array_values( array_unique( array_filter( $taxonomies ) ) ); /** * Filter the allowlist of term taxonomies eligible for Yoast sync. * * @param array $taxonomies Allowed taxonomies. */ return apply_filters( 'ai_content_by_parallax_yoast_term_taxonomies', $taxonomies ); } /** * Check whether a taxonomy is eligible for Yoast term sync. * * @param string $taxonomy Taxonomy slug. * @return bool */ private static function is_allowed_taxonomy( string $taxonomy ): bool { return in_array( $taxonomy, self::get_allowed_taxonomies(), true ); } /** * Best-effort Yoast taxonomy meta updates using Yoast's API when available. * * @param int $term_id Term ID. * @param string $taxonomy Taxonomy slug. * @param array $changes Sanitized field changes (null removes). * @return bool True if API handled the update (even if no changes needed). */ private static function update_yoast_term_meta_via_api( int $term_id, string $taxonomy, array $changes ): bool { if ( ! class_exists( 'WPSEO_Taxonomy_Meta' ) ) { return false; } $current = self::get_yoast_term_meta_via_api( $taxonomy, $term_id ); if ( null === $current ) { return false; } $updated = $current; $changed = false; $repaired = false; foreach ( self::TERM_META_ALLOWED_FIELDS as $field ) { if ( ! array_key_exists( $field, $updated ) ) { continue; } $did_repair = false; $coerced = self::coerce_yoast_term_field_value( $updated[ $field ], $did_repair ); if ( $did_repair ) { $updated[ $field ] = $coerced; $changed = true; $repaired = true; } } foreach ( $changes as $field => $value ) { if ( null === $value ) { if ( array_key_exists( $field, $updated ) ) { unset( $updated[ $field ] ); $changed = true; } continue; } if ( ! array_key_exists( $field, $updated ) || $updated[ $field ] !== $value ) { $updated[ $field ] = $value; $changed = true; } } foreach ( self::TERM_META_ALLOWED_FIELDS as $field ) { if ( ! array_key_exists( $field, $updated ) || null === $updated[ $field ] ) { continue; } $did_repair = false; $coerced = self::coerce_yoast_term_field_value( $updated[ $field ], $did_repair ); if ( $did_repair ) { $updated[ $field ] = $coerced; $changed = true; } } if ( ! $changed ) { return true; } if ( $repaired ) { self::log_debug( 'Repaired Yoast term meta values stored as arrays.', array( 'term_id' => $term_id, 'taxonomy' => $taxonomy, ) ); } return self::call_yoast_set_values( $taxonomy, $term_id, $updated ); } /** * Read Yoast term meta via API when available. * * @param string $taxonomy Taxonomy slug. * @param int $term_id Term ID. * @return array|null */ private static function get_yoast_term_meta_via_api( string $taxonomy, int $term_id ): ?array { if ( ! class_exists( 'WPSEO_Taxonomy_Meta' ) || ! method_exists( 'WPSEO_Taxonomy_Meta', 'get_term_meta' ) ) { return null; } $data = self::call_yoast_get_term_meta( $taxonomy, $term_id, array() ); if ( null === $data ) { return null; } $repaired = false; foreach ( self::TERM_META_ALLOWED_FIELDS as $field ) { if ( ! array_key_exists( $field, $data ) ) { continue; } $did_repair = false; $data[ $field ] = self::coerce_yoast_term_field_value( $data[ $field ], $did_repair ); if ( $did_repair ) { $repaired = true; } } if ( $repaired ) { self::log_debug( 'Repaired Yoast term meta values stored as arrays on read.', array( 'term_id' => $term_id, 'taxonomy' => $taxonomy, ) ); self::call_yoast_set_values( $taxonomy, $term_id, $data ); } return $data; } /** * Write Yoast term meta via API when available. * * @param string $taxonomy Taxonomy slug. * @param int $term_id Term ID. * @param array $values Full meta array for the term. * @return bool */ private static function call_yoast_set_values( string $taxonomy, int $term_id, array $values ): bool { if ( ! class_exists( 'WPSEO_Taxonomy_Meta' ) ) { return false; } $method = null; if ( method_exists( 'WPSEO_Taxonomy_Meta', 'set_values' ) ) { $method = 'set_values'; } elseif ( method_exists( 'WPSEO_Taxonomy_Meta', 'set_term_meta' ) ) { $method = 'set_term_meta'; } if ( null === $method ) { return false; } $attempts = array( array( $taxonomy, $term_id, $values ), array( $term_id, $taxonomy, $values ), ); $errors = array(); foreach ( $attempts as $index => $args ) { try { call_user_func_array( array( 'WPSEO_Taxonomy_Meta', $method ), $args ); return true; } catch ( \Throwable $error ) { $errors[ $index ] = sprintf( '%s: %s (file: %s, line: %d)', get_class( $error ), $error->getMessage(), $error->getFile(), $error->getLine() ); continue; } } if ( (bool) Settings::get( 'enable_debug_support', false ) ) { Logger::write_log( 'warning', '[YoastBridge] Yoast term meta set failed after multiple attempts.', array( 'method' => $method, 'taxonomy' => $taxonomy, 'term_id' => $term_id, 'errors' => $errors, ) ); } return false; } /** * Call Yoast term meta read method with a resilient argument order. * * @param string $taxonomy Taxonomy slug. * @param int $term_id Term ID. * @param array $default_value Default value. * @return array|null */ private static function call_yoast_get_term_meta( string $taxonomy, int $term_id, array $default_value ): ?array { $attempts = array( array( $taxonomy, $term_id, $default_value ), array( $term_id, $taxonomy, $default_value ), ); $errors = array(); foreach ( $attempts as $index => $args ) { try { $data = call_user_func_array( array( 'WPSEO_Taxonomy_Meta', 'get_term_meta' ), $args ); return is_array( $data ) ? $data : array(); } catch ( \Throwable $error ) { $errors[ $index ] = sprintf( '%s: %s (file: %s, line: %d)', get_class( $error ), $error->getMessage(), $error->getFile(), $error->getLine() ); continue; } } if ( (bool) Settings::get( 'enable_debug_support', false ) ) { Logger::write_log( 'warning', '[YoastBridge] Yoast term meta read failed after multiple attempts.', array( 'taxonomy' => $taxonomy, 'term_id' => $term_id, 'default_value' => $default_value, 'errors' => $errors, ) ); } return null; } /** * Coerce Yoast term meta values into sanitized strings. * * @param mixed $value Raw value. * @param bool $did_repair Whether coercion occurred. * @return string */ private static function coerce_yoast_term_field_value( $value, bool &$did_repair ): string { $did_repair = false; if ( is_string( $value ) ) { return $value; } $did_repair = true; if ( is_array( $value ) ) { $scalar_candidates = array(); foreach ( $value as $candidate ) { if ( is_scalar( $candidate ) ) { $scalar_candidates[] = (string) $candidate; } } if ( $scalar_candidates ) { $selected = sanitize_text_field( $scalar_candidates[0] ); if ( count( $scalar_candidates ) > 1 && (bool) Settings::get( 'enable_debug_support', false ) ) { Logger::write_log( 'warning', '[YoastBridge] Yoast term meta value contained multiple scalar candidates; using first value.', array( 'selected_value' => $selected, 'discarded_values' => array_map( 'sanitize_text_field', array_slice( $scalar_candidates, 1 ) ), ) ); } return $selected; } return ''; } if ( is_object( $value ) && method_exists( $value, '__toString' ) ) { return sanitize_text_field( (string) $value ); } if ( is_scalar( $value ) ) { return sanitize_text_field( (string) $value ); } return ''; } /** * Read a Yoast term field via API when available. * * @param string $taxonomy Taxonomy slug. * @param int $term_id Term ID. * @param string $field Field key. * @return string|null */ private static function get_yoast_term_field_via_api( string $taxonomy, int $term_id, string $field ): ?string { if ( ! class_exists( 'WPSEO_Taxonomy_Meta' ) ) { return null; } $data = self::get_yoast_term_meta_via_api( $taxonomy, $term_id ); if ( null === $data || ! array_key_exists( $field, $data ) ) { return null; } $value = $data[ $field ]; return is_string( $value ) ? $value : ''; } /** * Log debug messages when plugin debug is enabled. * * @param string $message Log message. * @param array $context Optional context. */ private static function log_debug( string $message, array $context = array() ): void { if ( ! (bool) Settings::get( 'enable_debug_support', false ) ) { return; } Logger::write_log( 'info', '[YoastBridge] ' . $message, $context ); } /** * Retrieve a Yoast term field from the taxonomy meta option. * * @param string $taxonomy Taxonomy slug. * @param int $term_id Term ID. * @param string $field Field key. * @return string|null */ private static function get_yoast_term_field( string $taxonomy, int $term_id, string $field ): ?string { $api_value = self::get_yoast_term_field_via_api( $taxonomy, $term_id, $field ); if ( null !== $api_value ) { return $api_value; } $option = self::get_wpseo_taxonomy_meta(); if ( ! isset( $option[ $taxonomy ][ $term_id ] ) || ! array_key_exists( $field, $option[ $taxonomy ][ $term_id ] ) ) { return null; } $value = $option[ $taxonomy ][ $term_id ][ $field ]; return is_string( $value ) ? $value : ''; } /** * Set a Yoast term field value in the taxonomy option. * * @param string $taxonomy Taxonomy slug. * @param int $term_id Term ID. * @param string $field Field key. * @param string $value Field value. */ private static function set_yoast_term_field( string $taxonomy, int $term_id, string $field, string $value ): void { try { self::update_wpseo_taxonomy_meta_for_term( $term_id, $taxonomy, array( $field => $value ) ); } catch ( \Throwable $error ) { self::log_debug( 'Yoast term field update failed; continuing without fatal error.', array( 'term_id' => $term_id, 'taxonomy' => $taxonomy, 'field' => $field, 'error' => $error->getMessage(), ) ); } } /** * Delete a Yoast term field value in the taxonomy option. * * @param string $taxonomy Taxonomy slug. * @param int $term_id Term ID. * @param string $field Field key. */ private static function delete_yoast_term_field( string $taxonomy, int $term_id, string $field ): void { self::update_wpseo_taxonomy_meta_for_term( $term_id, $taxonomy, array( $field => null ) ); } }