*/ function aafm_wpml_active_language_codes(): array { if ( ! aafm_wpml_active() ) { return array(); } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party WPML hook. $langs = apply_filters( 'wpml_active_languages', null ); if ( ! is_array( $langs ) ) { return array(); } return array_values( array_map( 'strval', array_keys( $langs ) ) ); } /** The site default language code, or null when WPML is off. */ function aafm_wpml_default_language(): ?string { if ( ! aafm_wpml_active() ) { return null; } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party WPML hook. $code = apply_filters( 'wpml_default_language', null ); return is_string( $code ) && '' !== $code ? $code : null; } /** The current request language code, or null when WPML is off. */ function aafm_wpml_current_language(): ?string { if ( ! aafm_wpml_active() ) { return null; } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party WPML hook. $code = apply_filters( 'wpml_current_language', null ); return is_string( $code ) && '' !== $code ? $code : null; } /** * The reusable `lang` input-schema property. Spread into an ability's * `properties` array. Kept as a fragment so the declaration never drifts. * * @return array */ function aafm_lang_schema_fragment(): array { return array( 'lang' => array( 'type' => 'string', 'description' => __( 'WPML language code to scope the query to (for example "en"), or "all" to span every active language. On a paginated list, "all" applies page and per_page separately to EACH active language and concatenates the results in language order, so the response can hold more than per_page items; total is the sum of every language\'s own match count. A code that is not an active WPML language is rejected with an error naming the valid codes. Ignored when WPML is not active. When omitted, the site default language is used.', 'agent-abilities-for-mcp' ), ), ); } /** * Resolve a requested language from validated input. * * An unknown code under active WPML is REFUSED, not coerced: the old null-coercion both * served the default language silently and reported `language: null`, which the output * schema documents as "WPML inactive" - two false statements in one response. With * WPML off, `lang` stays documented as ignored, so null is returned without inspection. * * @param array $input Ability input. * @return string|null|WP_Error A valid active language code, the literal 'all', null * (no scoping: WPML off or none requested), or a WP_Error * naming the valid codes for an unknown code. */ function aafm_resolve_lang( array $input ) { if ( ! aafm_wpml_active() || ! isset( $input['lang'] ) ) { return null; } $lang = (string) $input['lang']; if ( 'all' === $lang ) { return 'all'; } $codes = aafm_wpml_active_language_codes(); if ( in_array( $lang, $codes, true ) ) { return $lang; } return new WP_Error( 'aafm_invalid_lang', sprintf( /* translators: 1: the requested language code, 2: comma-separated list of active WPML language codes. */ __( '"%1$s" is not an active WPML language code on this site. Valid codes: %2$s, or "all" to span every language.', 'agent-abilities-for-mcp' ), $lang, implode( ', ', $codes ) ) ); } /** * The set of language codes an `aafm_resolve_lang()` result of 'all' should iterate: every * WPML active language code, or - the degenerate case where WPML reports itself loaded but * names no active languages - a single null entry, so the caller runs its query exactly once, * unscoped, rather than silently returning nothing. aafm_with_language() already treats null * as "no switch", so every element of this array can be passed straight to it. * * @return array */ function aafm_wpml_all_language_codes_for_iteration(): array { $codes = aafm_wpml_active_language_codes(); return array() === $codes ? array( null ) : $codes; } /** * Run $fn inside a WPML language scope, then restore the original language. * Snapshot-switch-restore per the house wordpress-safety rule; restores even * when $fn throws. When $lang is null or WPML is off, $fn runs unscoped. * * @param string|null $lang Target code, 'all', or null for no switch. * @param callable $fn Zero-arg callback. * @return mixed The callback's return value. * * phpcs:disable Universal.NamingConventions.NoReservedKeywordParameterNames.fnFound -- $fn is the contracted parameter name in the plan. */ function aafm_with_language( ?string $lang, callable $fn ) { // phpcs:enable Universal.NamingConventions.NoReservedKeywordParameterNames.fnFound if ( null === $lang || ! aafm_wpml_active() ) { return $fn(); } $original = aafm_wpml_current_language(); // Investigated, low and only suspected: if WPML reports itself loaded but // $original comes back null, this skips the switch and $lang runs under whatever ambient // language WPML actually holds - which looks like the same silent-wrong-language class fixed // three times on this branch. Left alone deliberately, for two reasons. First, $original can // only be null here if the documented `wpml_current_language` filter itself returns an empty // or non-string value while `wpml_loaded` has fired - stock WPML always resolves to a real // code (falling back to the site default) once loaded, so the only path to null is a // misbehaving third party overriding that exact filter, and no such path is known to be // reachable in practice. Second, and more importantly, the "obvious" fix of switching // whenever `$lang !== $original` makes things WORSE in exactly that edge case: the restore in // the finally block below would then call `do_action( 'wpml_switch_language', null )`, // handing WPML's own action a value it does not document accepting. A crash or a corrupted // "current language" there would outlive this one call, silently mis-scoping every WPML-aware // operation for the rest of the request - a strictly worse failure than this function quietly // not switching. So the asymmetry is a fail-safe: refuse to switch into a state this function // cannot safely restore out of, rather than guess. $switch = ( null !== $original && $lang !== $original ); if ( $switch ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party WPML hook. do_action( 'wpml_switch_language', $lang ); } try { return $fn(); } finally { if ( $switch ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party WPML hook. do_action( 'wpml_switch_language', $original ); } } } /** * A post's WPML language code, or null when WPML is off. * * @param int $post_id Post id. */ function aafm_wpml_post_language( int $post_id ): ?string { if ( ! aafm_wpml_active() ) { return null; } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party WPML hook. $details = apply_filters( 'wpml_post_language_details', null, $post_id ); return is_array( $details ) && ! empty( $details['language_code'] ) ? (string) $details['language_code'] : null; } /** * Per-status counts for a post type, scoped to a WPML language when active. * Mirrors the shape of wp_count_posts() (status => int) but, under WPML, is * language-correct because wp_count_posts() is language-blind. When WPML is off * this delegates to wp_count_posts() so non-multilingual sites are unaffected. * * $perm is forwarded to the WPML-off wp_count_posts() delegation only - the WPML-on branch * below always runs a per-status WP_Query with no perm restriction of its own, leaving * capability filtering to each ability's own downstream logic. The parameter exists because * two callers need different WPML-off behavior from the SAME helper: aafm/count-posts always * used wp_count_posts($type, 'readable') and keeps that default, while aafm/wc-count-products * originally called wp_count_posts('product') with no perm argument at all - passing '' here * preserves that exact byte-for-byte non-WPML behavior instead of silently tightening it. * * @param string $type Post type (already validated by the caller). * @param string|null $lang Resolved language, 'all', or null. * @param string $perm Permission argument forwarded to wp_count_posts() on the WPML-off * path only ('readable' or ''). Defaults to 'readable'. * @return array Status => count. */ function aafm_wpml_count_posts_by_status( string $type, ?string $lang, string $perm = 'readable' ): array { if ( ! aafm_wpml_active() ) { $counts = (array) wp_count_posts( $type, $perm ); return array_map( 'intval', $counts ); } $statuses = get_post_stati(); $count_for_lang = static function () use ( $type, $statuses ): array { $out = array(); foreach ( $statuses as $status ) { $q = new WP_Query( array( 'post_type' => $type, 'post_status' => $status, 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => false, 'suppress_filters' => false, ) ); $out[ $status ] = (int) $q->found_posts; } return $out; }; if ( 'all' === $lang ) { // Sum per status across every active language - safe because each language's matching // posts are DISTINCT rows (a translation is its own post), never double-counted. $totals = array_fill_keys( $statuses, 0 ); foreach ( aafm_wpml_all_language_codes_for_iteration() as $code ) { foreach ( aafm_with_language( $code, $count_for_lang ) as $status => $n ) { $totals[ $status ] += $n; } } return $totals; } return aafm_with_language( $lang, $count_for_lang ); } /** * Translate an element id into a target language via wpml_object_id (plain * element types only). Returns the original id when WPML is off or when there * is no translation (return_original_if_missing = true). * * @param int $id Element id. * @param string $type Plain type: post, page, {cpt}, category, post_tag, attachment. * @param string|null $lang Target language, or null for current. */ function aafm_wpml_translated_id( int $id, string $type, ?string $lang ): int { if ( ! aafm_wpml_active() ) { return $id; } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party WPML hook. $translated = apply_filters( 'wpml_object_id', $id, $type, true, $lang ); return is_numeric( $translated ) ? (int) $translated : $id; }