prepare(): values and ID lists as placeholders, table and // column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared // where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins). /** * Lang (0.98.168) — which language an article is in, on sites that run * Polylang or WPML. Everything here is a no-op on a one-language site: * active() is false, of() returns '', same() is always true. * * Used so that Clusterlane never mixes languages where it matters: * - Text Match and Link Suggestions only suggest links between articles in * the same language (a Swedish text never gets a link to the English * version of an article with the same title); * - AI writes link sentences, SEO titles and descriptions in the article's * own language, and checks clusters in their articles' language; * - the article lists can be filtered by language, and the CSV exports say it; * - a slug that exists in several languages resolves to the one in the * linking article's language. * * Polylang keeps the language as a term in its "language" taxonomy; WPML in * its icl_translations table. Both are read in bulk with one query. */ final class Lang { /** 'polylang', 'wpml' or '' (one language). */ public static function plugin(): string { static $p = null; if ( $p !== null ) return $p; $found = ''; if ( function_exists( 'pll_get_post_language' ) && function_exists( 'pll_languages_list' ) ) $found = 'polylang'; elseif ( defined( 'ICL_SITEPRESS_VERSION' ) && has_filter( 'wpml_active_languages' ) ) $found = 'wpml'; // Before all plugins have loaded, "none" may just mean "not loaded yet" — don't remember it. if ( $found !== '' || did_action( 'plugins_loaded' ) ) $p = $found; return $found; } /** True when the site has a multilingual plugin with at least two languages. */ public static function active(): bool { return self::plugin() !== '' && count( self::languages() ) > 1; } /** * The site's languages: code → [ 'name' => native name, 'locale' => sv_SE, 'english' => Swedish ]. * @return array */ public static function languages(): array { static $cache = null; if ( $cache !== null ) return $cache; if ( ! did_action( 'init' ) ) return []; // languages are registered on init $out = []; if ( self::plugin() === 'polylang' ) { foreach ( (array) pll_languages_list( [ 'fields' => '' ] ) as $l ) { if ( ! is_object( $l ) || empty( $l->slug ) ) continue; $out[ (string) $l->slug ] = [ 'name' => (string) $l->name, 'locale' => (string) ( $l->locale ?? '' ), 'english' => self::english_name( (string) ( $l->locale ?? $l->slug ), (string) $l->name ) ]; } } elseif ( self::plugin() === 'wpml' ) { foreach ( (array) apply_filters( 'wpml_active_languages', null, [ 'skip_missing' => 0 ] ) as $code => $l ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's public API filter $name = (string) ( $l['native_name'] ?? $l['translated_name'] ?? $code ); $locale = (string) ( $l['default_locale'] ?? '' ); $out[ (string) $code ] = [ 'name' => $name, 'locale' => $locale, 'english' => self::english_name( $locale ?: (string) $code, (string) ( $l['translated_name'] ?? $name ) ) ]; } } return $cache = $out; } /** The language code of one article ('' when unknown or one-language site). */ public static function of( int $post_id ): string { if ( ! $post_id || self::plugin() === '' ) return ''; return self::map( [ $post_id ] )[ $post_id ] ?? ''; } /** * Language codes for many articles in one query. * @param int[] $ids * @return array post ID → language code (missing when unknown) */ public static function map( array $ids ): array { static $seen = []; $ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ) ) ) ); if ( ! $ids || self::plugin() === '' ) return []; $need = array_values( array_filter( $ids, fn( $id ) => ! array_key_exists( $id, $seen ) ) ); global $wpdb; foreach ( array_chunk( $need, 1000 ) as $chunk ) { if ( self::plugin() === 'polylang' ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT tr.object_id AS id, t.slug AS lang FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'language' INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id WHERE tr.object_id IN (" . implode( ',', array_fill( 0, count( $chunk ), '%d' ) ) . ')', $chunk ) ); } else { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT element_id AS id, language_code AS lang FROM {$wpdb->prefix}icl_translations WHERE element_type LIKE %s AND element_id IN (" . implode( ',', array_fill( 0, count( $chunk ), '%d' ) ) . ')', array_merge( [ $wpdb->esc_like( 'post_' ) . '%' ], $chunk ) ) ); } foreach ( $chunk as $id ) $seen[ $id ] = ''; foreach ( (array) $rows as $r ) $seen[ (int) $r->id ] = (string) $r->lang; } $out = []; foreach ( $ids as $id ) if ( ( $seen[ $id ] ?? '' ) !== '' ) $out[ $id ] = $seen[ $id ]; return $out; } /** True unless both articles have a known language and they differ. */ public static function same( int $a, int $b ): bool { if ( ! self::active() ) return true; $m = self::map( [ $a, $b ] ); return ! isset( $m[ $a ], $m[ $b ] ) || $m[ $a ] === $m[ $b ]; } /** * Keep only the IDs in $lang (and those with no known language). '' keeps all. * @param int[] $ids * @return int[] */ public static function filter_ids( array $ids, string $lang ): array { if ( $lang === '' || ! self::active() ) return array_values( $ids ); $m = self::map( $ids ); return array_values( array_filter( $ids, fn( $id ) => ! isset( $m[ (int) $id ] ) || $m[ (int) $id ] === $lang ) ); } /** SQL condition: the post (alias.ID) is in $lang. '' or one-language site → '1=1'. */ public static function sql( string $lang, string $alias = 'p' ): string { global $wpdb; if ( $lang === '' || ! self::active() ) return '1=1'; // The posts table alias (or none) goes in as an identifier. if ( self::plugin() === 'polylang' ) { if ( $alias === '' ) { return $wpdb->prepare( "EXISTS ( SELECT 1 FROM {$wpdb->term_relationships} ltr INNER JOIN {$wpdb->term_taxonomy} ltt ON ltt.term_taxonomy_id = ltr.term_taxonomy_id AND ltt.taxonomy = 'language' INNER JOIN {$wpdb->terms} lt ON lt.term_id = ltt.term_id AND lt.slug = %s WHERE ltr.object_id = ID )", $lang ); } return $wpdb->prepare( "EXISTS ( SELECT 1 FROM {$wpdb->term_relationships} ltr INNER JOIN {$wpdb->term_taxonomy} ltt ON ltt.term_taxonomy_id = ltr.term_taxonomy_id AND ltt.taxonomy = 'language' INNER JOIN {$wpdb->terms} lt ON lt.term_id = ltt.term_id AND lt.slug = %s WHERE ltr.object_id = %i.ID )", $lang, $alias ); } if ( $alias === '' ) { return $wpdb->prepare( "EXISTS ( SELECT 1 FROM {$wpdb->prefix}icl_translations lit WHERE lit.element_id = ID AND lit.element_type = CONCAT( 'post_', post_type ) AND lit.language_code = %s )", $lang ); } return $wpdb->prepare( "EXISTS ( SELECT 1 FROM {$wpdb->prefix}icl_translations lit WHERE lit.element_id = %i.ID AND lit.element_type = CONCAT( 'post_', %i.post_type ) AND lit.language_code = %s )", $alias, $alias, $lang ); } /** * An instruction for an AI prompt: the language of this article, or '' on a * one-language site (the prompt's own rule then applies). */ public static function prompt_rule( int $post_id, string $what = 'Write' ): string { $name = self::english( self::of( $post_id ) ); return $name !== '' ? "{$what} in {$name} — the language of the article." : ''; } /** The most common language among these articles ('' when none known). */ public static function majority( array $ids ): string { $count = array_count_values( self::map( $ids ) ); if ( ! $count ) return ''; arsort( $count ); return (string) array_key_first( $count ); } /** English name of a language code, for AI prompts ("Swedish"). */ public static function english( string $code ): string { if ( $code === '' ) return ''; return self::languages()[ $code ]['english'] ?? $code; } /** "sv_SE" → "Swedish"; falls back to the plugin's name. */ private static function english_name( string $locale, string $fallback ): string { static $names = null; if ( $names === null ) { $common = [ 'sv' => 'Swedish', 'en' => 'English', 'de' => 'German', 'fr' => 'French', 'es' => 'Spanish', 'it' => 'Italian', 'nl' => 'Dutch', 'da' => 'Danish', 'nb' => 'Norwegian', 'nn' => 'Norwegian', 'no' => 'Norwegian', 'fi' => 'Finnish', 'pt' => 'Portuguese', 'pl' => 'Polish', 'ru' => 'Russian', 'ja' => 'Japanese', 'zh' => 'Chinese', 'ko' => 'Korean', 'ar' => 'Arabic', 'tr' => 'Turkish', 'cs' => 'Czech', 'el' => 'Greek' ]; $names = $common; } $base = strtolower( (string) preg_replace( '/[_-].*$/', '', $locale ) ); return $names[ $base ] ?? $fallback; } }