register_taxonomy();
}
// Register REST API endpoints for inline translations
add_action('rest_api_init', [$this, 'register_rest_routes']);
// Ensure DB table exists
$this->maybe_create_table();
// Frontend URL rewriting and language detection (after CPTs registered)
add_action('init', [$this, 'add_rewrite_rules'], 25);
// Second pass after late CPT registration (JetEngine, SureCart and other CPT
// plugins often register on init priority > 25). Re-registers the prefixed
// rules so late CPTs get their single permalink rule, and auto-flushes once if
// a CPT rule is missing — otherwise the catch-all page rule 404s the CPT URL.
add_action('init', [$this, 'add_rewrite_rules'], 99);
add_filter('query_vars', [$this, 'register_query_vars']);
add_action('init', [$this, 'init_language_detection'], 5);
add_action('parse_request', [$this, 'detect_language_from_request'], -100);
// After a default-language switch, 301 the now-stale old URLs (only on 404,
// and only when a redirect map was stored by the switch tool).
if (self::is_frontend_or_ajax() && get_option('aitrfoel_lang_switch_redirects')) {
add_action('template_redirect', [$this, 'maybe_redirect_after_lang_switch'], 1);
}
// Persist the resolved language in a cookie so unprefixed AJAX requests
// (WooCommerce cart fragments / mini-cart) can recover the current language.
if (self::is_frontend_or_ajax()) {
add_action('template_redirect', [$this, 'maybe_set_language_cookie'], 1);
}
// Switch WordPress locale on frontend based on detected language
if (self::is_frontend_or_ajax()) {
add_filter('locale', [$this, 'filter_locale']);
// Force-reload translations for the detected language as early as
// possible, so WooCommerce/theme UI strings that may have been loaded
// in the default language get switched (cart, checkout, mini-cart).
add_action('init', [$this, 'maybe_switch_locale_early'], 99);
// Force correct HTML lang attribute regardless of theme or SEO plugin overrides
add_filter('language_attributes', [$this, 'filter_language_attributes'], 999);
// Custom string translations — admin-defined overrides for UI strings
// that aren't in any .mo file (WooCommerce gateway titles/descriptions,
// checkout privacy text, third-party plugin strings). Registered on `wp`
// so the current language is known and resolved only once.
add_action('wp', [$this, 'register_string_translations']);
// Swiper height fixes
add_action('wp_footer', [$this, 'fix_jet_gallery_swiper_height'], 9999);
// Scopes frontend queries to the current language
add_action('pre_get_posts', [$this, 'filter_pre_get_posts'], 20);
}
// Permalink filtering for language prefixes
add_filter('post_link', [$this, 'filter_permalink'], 10, 2);
add_filter('page_link', [$this, 'filter_page_permalink'], 10, 2);
add_filter('post_type_link', [$this, 'filter_permalink'], 10, 2);
// Filter home_url to include language prefix (fixes logo link etc.)
if (self::is_frontend_or_ajax()) {
add_filter('home_url', [$this, 'filter_home_url'], 10, 4);
// Make WooCommerce's AJAX endpoint (cart fragments / mini-cart) carry the
// language prefix, so the fragment request is detected as the current
// language from the URL itself instead of an unprefixed default.
add_filter('woocommerce_ajax_get_endpoint', [$this, 'filter_wc_ajax_endpoint'], 10, 1);
}
// Frontend query filtering
if (self::is_frontend_or_ajax()) {
// Runs before WooCommerce's product_query (priority 10): converts a translated
// Shop page that resolved as a singular page into a clean product archive, so the
// product loop isn't pinned to the page's own ID (which yields zero products).
add_action('pre_get_posts', [$this, 'maybe_convert_shop_page_query'], 5);
add_action('pre_get_posts', [$this, 'filter_frontend_queries']);
// Filter taxonomy term names to show translated versions
add_filter('get_term', [$this, 'filter_term_name'], 10, 2);
add_filter('get_terms', [$this, 'filter_terms_names'], 10, 4);
// Filter WooCommerce and WordPress page IDs to translated versions
add_filter('pre_option_page_on_front', [$this, 'filter_translated_page_option'], 10, 2);
add_filter('pre_option_woocommerce_shop_page_id', [$this, 'filter_translated_page_option'], 10, 2);
add_filter('pre_option_woocommerce_cart_page_id', [$this, 'filter_translated_page_option'], 10, 2);
add_filter('pre_option_woocommerce_checkout_page_id', [$this, 'filter_translated_page_option'], 10, 2);
add_filter('pre_option_woocommerce_myaccount_page_id', [$this, 'filter_translated_page_option'], 10, 2);
add_filter('pre_option_woocommerce_terms_page_id', [$this, 'filter_translated_page_option'], 10, 2);
// Localize WooCommerce / Elementor "Products" widget queries to the current language.
// Manual selection (post__in/include) is remapped to the current-language translations;
// category / generic queries are scoped to the current language via tax_query so shared
// product categories don't leak default-language products into translated pages.
if (class_exists('WooCommerce') && get_option('aitrfoel_woocommerce_support', true)) {
add_filter('woocommerce_shortcode_products_query', [$this, 'filter_products_widget_query'], 20, 3);
add_filter('woocommerce_shortcode_products_query_results', [$this, 'filter_products_widget_results'], 20, 2);
// Display-time safety net: if a source-language product is rendered on a
// translated page (single product, related/upsell/cross-sell, Elementor
// single-product templates), show its translated short description.
add_filter('woocommerce_short_description', [$this, 'filter_product_short_description'], 20, 1);
// Mini-cart / cart: point each cart item's link and name to the
// current-language translation, so the basket preview doesn't send
// visitors to the source-language product page.
add_filter('woocommerce_cart_item_permalink', [$this, 'filter_cart_item_permalink'], 20, 3);
add_filter('woocommerce_cart_item_name', [$this, 'filter_cart_item_name'], 20, 3);
// "View cart" / "Checkout" links (mini-cart buttons, etc.): resolve
// directly to the current-language cart/checkout page. This is more
// direct than the pre_option_* page-id swap and avoids option-cache
// timing issues during AJAX fragment rendering.
add_filter('woocommerce_get_cart_url', [$this, 'filter_wc_cart_url'], 20, 1);
add_filter('woocommerce_get_checkout_url', [$this, 'filter_wc_checkout_url'], 20, 1);
// Routing-independent loop localization: when any product loop (Elementor
// Products / Loop Grid, related, up-sells, shortcodes, theme loops) renders
// a source-language product on a translated page, swap the post/product
// globals to the translation so title, link, price, short description and
// add-to-cart all render in the current language. This covers widgets that
// build their query without passing through woocommerce_shortcode_products_query.
add_action('the_post', [$this, 'localize_loop_product'], 10, 1);
// Scope Elementor Pro's native Products / Loop Grid widget queries to the
// current language. These widgets build their own WP_Query via
// elementor/query/query_args and bypass woocommerce_shortcode_products_query.
add_filter('elementor/query/query_args', [$this, 'filter_elementor_query_lang'], 20, 2);
}
// Localize absolute internal links (e.g. /fr/...) inside post content and Elementor
// widgets to the current-language translated target.
if (get_option('aitrfoel_localize_internal_links', true)) {
add_filter('the_content', [$this, 'localize_internal_links'], 99);
add_filter('elementor/frontend/the_content', [$this, 'localize_internal_links'], 99);
// Catch Elementor widgets whose links are rendered outside the_content
// (Button, Icon, Image, Call to Action, etc.).
add_filter('elementor/widget/render_content', [$this, 'localize_elementor_widget_links'], 99, 2);
}
// Scope the "Previous/Next post" navigation (get_adjacent_post, used by
// themes like Shoptimizer for single blog posts) to the viewed post's
// language, so an English post links to English neighbours instead of
// leaking French/Spanish/German translations.
add_filter('get_previous_post_where', [$this, 'filter_adjacent_post_where'], 10, 5);
add_filter('get_next_post_where', [$this, 'filter_adjacent_post_where'], 10, 5);
// Scope JetEngine Listing Grid / Query Builder queries to the current
// language, so a listing of a translatable CPT shows the current-language
// items (e.g. English "realisaties") instead of the source-language posts.
add_filter('jet-engine/listing/grid/posts-query-args', [$this, 'filter_jetengine_query_lang'], 20, 1);
add_filter('jet-engine/query-builder/posts/query-args', [$this, 'filter_jetengine_query_lang'], 20, 1);
add_filter('jet-engine/query-builder/queries/query-args', [$this, 'filter_jetengine_query_lang'], 20, 1);
// Astra Header Builder custom buttons: localize their link to the current
// language's translation (these are theme-builder elements, not menu items
// or content links, so they aren't covered elsewhere). Covers up to 4
// buttons and both the array ("-link-option") and string ("-link") shapes.
for ($aitrfoel_btn = 1; $aitrfoel_btn <= 4; $aitrfoel_btn++) {
add_filter("theme_mod_header-button{$aitrfoel_btn}-link-option", [$this, 'localize_astra_button_link'], 20, 1);
add_filter("theme_mod_header-button{$aitrfoel_btn}-link", [$this, 'localize_astra_button_link'], 20, 1);
// Translate the button label text via String Translations (Tools →
// String Translations): the label is an Astra theme_mod rendered as
// static HTML, so it never reaches gettext on its own.
add_filter("theme_mod_header-button{$aitrfoel_btn}-text", [$this, 'filter_string_value'], 20, 1);
}
// Robust, theme-level fallback: buffer the Astra header and localize every
// internal link in it to the current-language translation. This catches
// header-builder custom buttons, custom HTML, and any other theme-rendered
// links that aren't menu items — without per-client workarounds. The
// language switcher's intentional cross-language links are preserved (they
// are wrapped in markers).
if (get_option('aitrfoel_localize_internal_links', true)) {
add_action('astra_header_before', [$this, 'start_header_link_localization'], 0);
add_action('astra_header_after', [$this, 'end_header_link_localization'], 99999);
}
}
// SEO: hreflang tags
add_action('wp_head', [$this, 'output_hreflang_tags']);
// Sync page attributes (menu_order, post_date, post_date_gmt)
add_action('save_post', [$this, 'sync_translation_attributes'], 10, 3);
// Redirect unprefixed default-language URLs to prefixed when option is enabled
add_action('template_redirect', [$this, 'maybe_redirect_default_lang_prefix']);
// Prevent WordPress canonical redirect from stripping language prefix
add_filter('redirect_canonical', [$this, 'filter_redirect_canonical'], 10, 2);
// Preload language switcher CSS in
for all frontend pages
add_action('wp_head', [$this, 'maybe_preload_switcher_css'], 99);
// Language switcher shortcode
add_action('init', [$this, 'register_shortcode']);
// Language switcher widget
add_action('widgets_init', [$this, 'register_widget']);
// Nav menu language switcher items
add_filter('wp_nav_menu_items', [$this, 'add_language_switcher_to_menu'], 20, 2);
// Per-language menu assignment: swap nav menu based on current language
if (!is_admin()) {
add_filter('wp_nav_menu_args', [$this, 'filter_nav_menu_by_language'], 10, 1);
// Runtime: swap menu item URLs to translated posts
add_filter('wp_nav_menu_objects', [$this, 'filter_menu_item_urls'], 10, 2);
// Redirect untranslated pages to default language
add_action('template_redirect', [$this, 'maybe_redirect_untranslated_page']);
}
// Per-language widget visibility
if (!is_admin()) {
add_filter('widget_display_callback', [$this, 'filter_widget_by_language'], 10, 3);
}
add_action('in_widget_form', [$this, 'widget_language_form'], 10, 3);
add_filter('widget_update_callback', [$this, 'widget_language_save'], 10, 4);
// Elementor Pro: swap header/footer/section templates to translated versions
// Strategy (mirrors "Connect Polylang for Elementor" plugin):
// 1. Translated templates have NO _elementor_conditions in DB.
// 2. Elementor resolves candidates using only the original (default-lang) template's conditions.
// 3. Once Elementor has picked the original template ID, we swap it to the translation via
// the elementor/theme/get_location_templates/template_id filter.
// 4. The get_post_metadata filter ensures translated templates always return empty conditions
// even if conditions were accidentally written to DB (belt-and-suspenders).
if (!is_admin()) {
add_filter('elementor/theme/get_location_templates/template_id', [$this, 'swap_elementor_template_id_to_translation'], 10, 1);
// Fallback: intercept Elementor data loading for any template
add_filter('elementor/frontend/builder_content_data', [$this, 'filter_elementor_template_data'], 10, 2);
// Swap the id attribute of the Elementor Pro [elementor-template id="X"]
// shortcode to the current-language translation BEFORE Elementor renders
// it. The builder_content_data filter above is a fallback for when this
// entry point is bypassed; doing it at the shortcode boundary is the most
// direct path and avoids any per-document caching keyed on the source id.
add_filter('pre_do_shortcode_tag', [$this, 'swap_elementor_template_shortcode_id'], 10, 4);
// Swap Gutenberg form block IDs (SureForms srfm/form, CF7, WPForms) to the current language's translation at render time
add_filter('render_block_data', [$this, 'swap_form_block_id_at_runtime'], 10, 2);
// JetThemeCore: swap the template id it is about to render to the current
// language's translation. Confirmed in jet-theme-core 2.3.x:
// - location render (single/header/footer/archive, Elementor + block editor):
// jet-theme-core/location/render/template-id
// - page templates assigned by conditions:
// jet-theme-core/page-template-conditions/page-template-id
add_filter('jet-theme-core/location/render/template-id', [$this, 'swap_jetthemecore_template_id'], 20, 1);
add_filter('jet-theme-core/page-template-conditions/page-template-id', [$this, 'swap_jetthemecore_template_id'], 20, 1);
}
// Always (admin + frontend): make translated templates appear to have no conditions,
// so they never compete with the original in Elementor's conditions resolver.
add_filter('get_post_metadata', [$this, 'elementor_conditions_empty_on_translations'], 10, 3);
// Front-end only: serve the translated Elementor data of a JetEngine listing
// item (post type 'jet-engine') when it's rendered on a non-default language,
// so the listing's static text (labels like "KLANT") shows in the right
// language. Works for the front end and JetSmartFilters AJAX, since both read
// the listing's _elementor_data.
if (!is_admin() || wp_doing_ajax()) {
add_filter('get_post_metadata', [$this, 'swap_jetengine_listing_data'], 10, 3);
}
// Front-end + AJAX: translate JetSmartFilters labels at render time. This is
// the single point where every option-based filter (radio, checkboxes, select,
// color-image, …) exposes its final args, including the prepended "Show all"
// option, so it covers term option labels and the admin-entered "all" label in
// both the initial render and AJAX re-renders. Term labels resolve from term
// meta directly (so it works even when JetSmartFilters' indexer caches the
// default-language names); admin strings flow through the custom-string map.
if (!is_admin() || wp_doing_ajax()) {
add_filter('jet-smart-filters/filter-instance/args', [$this, 'translate_jsf_filter_args'], 20, 2);
}
// Front-end: translate JetFormBuilder field blocks (labels, descriptions,
// placeholders, option labels, button text) before they render. Uses WP
// core's render_block_data filter, so it depends only on the stable
// jet-forms/* block names and attribute keys, not JetFormBuilder internals.
if (!is_admin()) {
add_filter('render_block_data', [$this, 'translate_jfb_block_attrs'], 10, 1);
}
// Front-end + AJAX: translate the three most popular standalone form plugins
// at render time (labels, descriptions, placeholders, option labels, button
// text, confirmation messages), each through that plugin's own render hook.
// Strings resolve through the custom-string map; the Auto-translate Strings
// tool collects them from every form so there's no manual data entry.
if (!is_admin() || wp_doing_ajax()) {
// WPForms — full $form_data array (fields, choices, settings).
add_filter('wpforms_frontend_form_data', [$this, 'translate_wpforms_form_data'], 20, 1);
// Gravity Forms — $form array with GF_Field objects.
add_filter('gform_pre_render', [$this, 'translate_gform_form'], 20, 1);
// Contact Form 7 — rendered form HTML (text nodes) and result messages.
add_filter('wpcf7_form_elements', [$this, 'translate_cf7_form_html'], 20, 1);
add_filter('wpcf7_display_message', [$this, 'translate_cf7_display_message'], 20, 2);
// SureCart — products are API-first and rendered server-side from the
// product model. Its base Model exposes a getter filter, so translating
// the `name` / `description` attributes once reaches every product block,
// the collection grid, and checkout line items in a single place.
if (get_option('aitrfoel_surecart_support', true)) {
add_filter('surecart/product/get_attribute', [$this, 'translate_surecart_product_attribute'], 20, 3);
}
}
// Merge any admin-configured "extra template post types" into both the
// translation scope and the render-swap scope, so non-developers can support
// additional builder template types (JetThemeCore parts, etc.) without code.
add_filter('aitrfoel_template_post_types', [$this, 'merge_extra_template_post_types']);
add_filter('aitrfoel_metadata_swap_template_types', [$this, 'merge_extra_template_post_types']);
// Warn admins when Elementor "Element Caching" is active, as it can serve
// cached (wrong-language) widget output across translated pages.
add_action('admin_notices', [$this, 'maybe_warn_elementor_element_cache']);
// REST API language fields
add_action('rest_api_init', [$this, 'register_rest_fields']);
// WordPress core sitemap: filter URLs to include only default-language posts
add_filter('wp_sitemaps_posts_query_args', [$this, 'filter_sitemap_query'], 10, 2);
// Admin bar: language switcher in toolbar
add_action('admin_bar_menu', [$this, 'add_admin_bar_language_switcher'], 100);
add_action('admin_head', [$this, 'admin_bar_switcher_css']);
add_action('wp_head', [$this, 'admin_bar_switcher_css']);
// Admin: language column in posts list
if (is_admin()) {
add_filter('manage_posts_columns', [$this, 'add_language_column']);
add_filter('manage_pages_columns', [$this, 'add_language_column']);
add_action('manage_posts_custom_column', [$this, 'render_language_column'], 10, 2);
add_action('manage_pages_custom_column', [$this, 'render_language_column'], 10, 2);
add_action('admin_head', [$this, 'language_column_css']);
// Language filter dropdown and query filtering
add_action('restrict_manage_posts', [$this, 'admin_language_filter_dropdown']);
add_action('pre_get_posts', [$this, 'admin_filter_posts_by_language']);
// Adjust status counts (All/Published/Draft/Trash) when language filter is active
add_filter('views_edit-post', [$this, 'filter_post_status_counts_by_language']);
add_filter('views_edit-page', [$this, 'filter_post_status_counts_by_language']);
}
}
// =========================================================================
// DATA LAYER: DB Table & Taxonomy
// =========================================================================
/**
* Register the language taxonomy
*/
public function register_taxonomy() {
// Collect all public post types (excluding media attachments and internal non-translatable types)
$post_types = array_keys(get_post_types(['public' => true], 'names'));
$excluded_types = [
'attachment', 'nav_menu_item', 'revision', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request',
'sf_submission', 'sureforms_submission'
];
$post_types = array_values(array_diff($post_types, $excluded_types));
if (empty($post_types)) {
$post_types = ['post', 'page'];
}
if (taxonomy_exists(self::TAXONOMY)) {
// Taxonomy already registered — ensure it covers all public post types
foreach ($post_types as $pt) {
if (!is_object_in_taxonomy($pt, self::TAXONOMY)) {
register_taxonomy_for_object_type(self::TAXONOMY, $pt);
}
}
return;
}
register_taxonomy(self::TAXONOMY, $post_types, [
'label' => __('Language', 'ai-translator-for-elementor-and-polylang'),
'public' => false,
'show_ui' => false,
'show_in_rest' => false,
'hierarchical' => false,
'rewrite' => false,
'query_var' => false,
]);
}
/**
* Create the translation groups table if it doesn't exist
*/
private function maybe_create_table() {
$installed_version = get_option(self::OPTION_DB_VERSION, '0');
if (version_compare($installed_version, self::DB_VERSION, '>=')) {
return;
}
$this->create_table();
update_option(self::OPTION_DB_VERSION, self::DB_VERSION);
}
/**
* Create the translation groups table
*/
public function create_table() {
global $wpdb;
$table_name = $wpdb->prefix . self::TABLE_NAME;
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table_name} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
group_id BIGINT(20) UNSIGNED NOT NULL,
post_id BIGINT(20) UNSIGNED NOT NULL,
language_code VARCHAR(10) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY post_language (post_id, language_code),
KEY group_id (group_id),
KEY post_id (post_id)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
}
/**
* Get the full table name with prefix
* @return string
*/
private function get_table_name() {
global $wpdb;
return $wpdb->prefix . self::TABLE_NAME;
}
/**
* Get the next available group ID
* @return int
*/
private function get_next_group_id() {
global $wpdb;
$table = $this->get_table_name();
$max = $wpdb->get_var("SELECT MAX(group_id) FROM {$table}");
return $max ? intval($max) + 1 : 1;
}
/**
* Get the group ID for a post
* @param int $post_id
* @return int|null
*/
private function get_post_group_id($post_id) {
global $wpdb;
$table = $this->get_table_name();
$group_id = $wpdb->get_var($wpdb->prepare(
"SELECT group_id FROM {$table} WHERE post_id = %d LIMIT 1",
$post_id
));
return $group_id ? intval($group_id) : null;
}
// =========================================================================
// LANGUAGE MANAGEMENT (Admin API)
// =========================================================================
/**
* Add a language to the built-in system
*
* @param string $code Language code (e.g., 'fr')
* @param string $name English name (e.g., 'French')
* @param string $native_name Native name (e.g., 'Français')
* @param bool $is_default Whether this is the default language
* @return bool
*/
public function add_language($code, $name, $native_name = '', $is_default = false) {
$languages = $this->get_stored_languages();
// Check if already exists
foreach ($languages as $lang) {
if ($lang['code'] === $code) {
return false; // Already exists
}
}
$languages[] = [
'code' => sanitize_text_field($code),
'name' => sanitize_text_field($name),
'native_name' => sanitize_text_field($native_name ?: $name),
'slug' => sanitize_text_field($code),
'is_default' => $is_default,
];
// If this is default, or if it's the first language, set as default
if ($is_default || count($languages) === 1) {
// Unset other defaults
foreach ($languages as &$lang) {
$lang['is_default'] = ($lang['code'] === $code);
}
update_option(self::OPTION_DEFAULT_LANG, $code);
}
update_option(self::OPTION_LANGUAGES, $languages);
$this->cached_languages = null; // Clear cache
// Ensure taxonomy is registered (may not be if called during AJAX)
$this->register_taxonomy();
// Ensure taxonomy term exists
if (!term_exists($code, self::TAXONOMY)) {
wp_insert_term($code, self::TAXONOMY, ['slug' => $code, 'description' => $name]);
}
// Best-effort: fetch the WordPress + plugin/theme (incl. WooCommerce) translation
// packs for this language, so front-end UI strings — cart, checkout, my-account,
// and other WooCommerce/core strings — render translated when filter_locale()
// switches the locale on // pages. The default language is the source and
// needs no pack. Failures never block adding the language.
if (!$is_default) {
try {
$this->maybe_download_language_pack($code);
} catch (\Throwable $e) {
if (class_exists('AITRFOEL_Logger')) {
AITRFOEL_Logger::log('Language pack download error for ' . $code . ': ' . $e->getMessage(), 'warning');
}
}
}
// Note: caller is responsible for calling flush_rewrite_rules() after batch operations
return true;
}
/**
* Best-effort install of translation packs for a language's WordPress locale.
*
* Downloads the core translation (which also registers the locale as "installed"),
* then runs the language-pack upgrader to fetch plugin/theme translations (including
* WooCommerce) for that locale. This makes WooCommerce's built-in strings render in
* the target language via locale switching, without the admin manually installing the
* WordPress site language. Entirely best-effort: every failure is swallowed/logged.
*
* @param string $code Short language code (e.g. 'ru')
* @return void
*/
private function maybe_download_language_pack($code) {
// Needs admin-side includes, filesystem and network access.
if (!is_admin()) {
return;
}
$locale = $this->get_wp_locale($code);
if (!$locale || $locale === 'en_US') {
return; // English / unknown locale needs no pack
}
// Already installed — nothing to do.
if (function_exists('get_available_languages') && in_array($locale, get_available_languages(), true)) {
return;
}
if (!function_exists('wp_download_language_pack')) {
$tr = ABSPATH . 'wp-admin/includes/translation-install.php';
if (file_exists($tr)) {
require_once $tr;
}
}
if (!function_exists('wp_download_language_pack')) {
return; // environment cannot install translations
}
// 1) Core translation for the locale (also marks it as installed/available).
$core = wp_download_language_pack($locale);
if (is_wp_error($core) || $core === false) {
if (class_exists('AITRFOEL_Logger')) {
AITRFOEL_Logger::log(sprintf('Language pack: core translation for %s unavailable (continuing).', $locale), 'warning');
}
} elseif (class_exists('AITRFOEL_Logger')) {
AITRFOEL_Logger::log(sprintf('Language pack: installed core translations for %s.', $locale), 'info');
}
// 2) Plugin + theme translations (incl. WooCommerce) for the now-installed locale.
if (!class_exists('Language_Pack_Upgrader')) {
$up = ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
if (file_exists($up)) {
require_once $up;
}
}
if (!class_exists('Language_Pack_Upgrader')) {
return;
}
// Refresh update transients so available translation packs for the new locale
// are discoverable by wp_get_translation_updates().
if (function_exists('wp_clean_update_cache')) {
wp_clean_update_cache();
}
if (function_exists('wp_update_plugins')) { wp_update_plugins(); }
if (function_exists('wp_update_themes')) { wp_update_themes(); }
if (function_exists('wp_version_check')) { wp_version_check(); }
$skin = class_exists('Automatic_Upgrader_Skin') ? new Automatic_Upgrader_Skin() : null;
$upgrader = $skin ? new Language_Pack_Upgrader($skin) : new Language_Pack_Upgrader();
// Empty first arg → upgrader pulls all pending translation updates for installed locales.
$upgrader->bulk_upgrade([], ['clear_update_cache' => false]);
if (class_exists('AITRFOEL_Logger')) {
AITRFOEL_Logger::log(sprintf('Language pack: ran plugin/theme translation install for %s.', $locale), 'info');
}
}
/**
* Remove a language from the built-in system
*
* @param string $code Language code
* @return bool
*/
public function remove_language($code) {
$languages = $this->get_stored_languages();
$default = $this->get_default_language();
// Don't allow removing the default language if it's the only one
if ($code === $default && count($languages) <= 1) {
return false;
}
$languages = array_values(array_filter($languages, function($lang) use ($code) {
return $lang['code'] !== $code;
}));
update_option(self::OPTION_LANGUAGES, $languages);
$this->cached_languages = null;
// If we removed the default, set a new default
if ($code === $default && !empty($languages)) {
$languages[0]['is_default'] = true;
update_option(self::OPTION_LANGUAGES, $languages);
update_option(self::OPTION_DEFAULT_LANG, $languages[0]['code']);
}
flush_rewrite_rules();
return true;
}
/**
* Set the default language
*
* @param string $code Language code
* @return bool
*/
/**
* Switch the site's default (unprefixed) language by moving slug ownership.
*
* In the built-in model, the default language's posts hold the clean slug at the
* root and other languages are served from suffixed slugs under a prefix. To make
* a different language the default, this re-homes slug ownership per translation
* group — the new default's post takes the clean base slug, the old default's post
* takes a `{base}-{oldlang}` slug — then flips the default flag. It captures old
* permalinks first and new permalinks after, storing a redirect map so old
* non-default URLs 301 to their new location.
*
* Works entirely within the existing (tested) routing; no resolver changes.
*
* @param string $new_default Target language code.
* @param bool $dry_run When true, only report what would change.
* @return array|WP_Error
*/
public function switch_default_language($new_default, $dry_run = true) {
global $wpdb;
$old_default = $this->get_default_language();
if (!$this->language_exists($new_default)) {
return new WP_Error('aitrfoel_bad_lang', __('Target language not found.', 'ai-translator-for-elementor-and-polylang'));
}
if ($new_default === $old_default) {
return new WP_Error('aitrfoel_same_lang', __('That language is already the default.', 'ai-translator-for-elementor-and-polylang'));
}
$table = $this->get_table_name();
$rows = $wpdb->get_results("SELECT group_id, post_id, language_code FROM {$table}"); // phpcs:ignore
$groups = [];
// Pre-flight integrity check. The migration trusts the translation-group table
// to know each post's language. If that table disagrees with the post's actual
// language term, the migration would move the WRONG content to the root. Detect
// any such mismatch and refuse to run until it's reconciled — this is the guard
// that prevents the "everything shows the wrong language" failure.
$mismatches = [];
foreach ((array) $rows as $r) {
$pid = (int) $r->post_id;
$table_lng = (string) $r->language_code;
$groups[(int) $r->group_id][$table_lng] = $pid;
$term_lng = $this->get_post_language($pid);
// Only compare when the post has an explicit term and it differs.
if ($term_lng && $term_lng !== $table_lng) {
$mismatches[] = $pid;
}
}
if (!empty($mismatches)) {
$msg = sprintf(
/* translators: %d: number of posts whose stored language is inconsistent. */
__('Cannot switch the default language: %d item(s) have an inconsistent language assignment (the translation-group record disagrees with the post\'s language tag). Run Tools → Repair Translation Links first, then try again. Nothing was changed.', 'ai-translator-for-elementor-and-polylang'),
count($mismatches)
);
return new WP_Error('aitrfoel_lang_mismatch', $msg, ['mismatches' => count($mismatches)]);
}
$plan = []; // each: new_post, new_from, new_to, new_rename, old_post, old_from, demoted
foreach ($groups as $members) {
$new_post = isset($members[$new_default]) ? (int) $members[$new_default] : 0;
if (!$new_post) {
continue;
}
$old_post = isset($members[$old_default]) ? (int) $members[$old_default] : 0;
if ($old_post === $new_post) {
continue; // same post both members — never act
}
$np = get_post($new_post);
if (!$np) {
continue;
}
// The new default takes the clean base slug: its own slug with a trailing
// -{newlang} suffix removed ("about-us-id" → "about-us"). If the slug is
// already clean and distinct (translated slugs like "tentang-kami"), it stays.
$base = $np->post_name;
$suffix = '-' . $new_default;
if (strlen($suffix) > 1 && substr($base, -strlen($suffix)) === $suffix) {
$base = substr($base, 0, -strlen($suffix));
}
$new_rename = ($np->post_name !== $base);
// The old default is demoted ONLY when it currently holds the exact slug the
// new default will take — a genuine collision. Distinct slugs are left as-is.
$old_slug = $old_post ? (string) get_post_field('post_name', $old_post) : '';
$collision = ($old_post && $old_slug !== '' && $old_slug === $base);
$demoted = $collision ? ($old_slug . '-' . $old_default) : '';
// Nothing to rename — the default flip alone routes this group correctly.
if (!$new_rename && !$collision) {
continue;
}
$plan[] = [
'new_post' => $new_post,
'new_from' => $np->post_name,
'new_to' => $base,
'new_rename' => $new_rename,
'old_post' => $collision ? $old_post : 0,
'old_from' => $collision ? $old_slug : '',
'demoted' => $demoted,
];
}
// Every group member's URL still changes when the default flips (the language
// prefix moves) even without a slug rename — collect them all for redirects.
$affected = [];
foreach ($groups as $members) {
if (isset($members[$new_default])) { $affected[(int) $members[$new_default]] = true; }
if (isset($members[$old_default])) { $affected[(int) $members[$old_default]] = true; }
}
$affected = array_keys($affected);
if ($dry_run) {
$sample = [];
foreach (array_slice($plan, 0, 25) as $p) {
$sample[] = [
'promote' => $p['new_rename'] ? ($p['new_from'] . ' → ' . $p['new_to']) : ($p['new_from'] . ' (slug unchanged)'),
'demote' => $p['old_post'] ? ($p['old_from'] . ' → ' . $p['demoted']) : '—',
];
}
return [
'dry_run' => true,
'old_default' => $old_default,
'new_default' => $new_default,
'group_count' => count($plan), // groups needing a slug change
'affected' => count($affected), // total items whose URL moves
'sample' => $sample,
];
}
// ── Execute ──────────────────────────────────────────────────────
// 1) Capture OLD permalinks for every affected post (URLs change by slug rename
// and/or by the language prefix moving when the default flips).
$old_urls = [];
foreach ($affected as $pid) {
$old_urls[$pid] = get_permalink($pid);
}
// 2) Apply only the necessary slug changes. Free a colliding slug (demote old)
// before the new default claims it.
foreach ($plan as $p) {
if ($p['old_post']) {
wp_update_post(['ID' => $p['old_post'], 'post_name' => $p['demoted']]);
}
if ($p['new_rename']) {
wp_update_post(['ID' => $p['new_post'], 'post_name' => $p['new_to']]);
}
}
// 3) Flip the default flag and rebuild rewrite rules.
$this->set_default_language($new_default);
$this->cached_languages = null;
if (function_exists('flush_rewrite_rules')) {
flush_rewrite_rules(false);
}
// 4) Build a redirect map (old path → new path) so old URLs don't 404.
$home_path = rtrim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
$relpath = function ($url) use ($home_path) {
$path = (string) wp_parse_url($url, PHP_URL_PATH);
if ($home_path !== '' && strpos($path, $home_path) === 0) {
$path = substr($path, strlen($home_path));
}
return '/' . trim($path, '/') . '/';
};
$map = [];
foreach ($affected as $pid) {
if (empty($old_urls[$pid])) {
continue;
}
$old = $relpath($old_urls[$pid]);
$new = $relpath(get_permalink($pid));
if ($old !== $new && $old !== '//') {
$map[$old] = $new;
}
}
update_option('aitrfoel_lang_switch_redirects', $map, false);
return [
'dry_run' => false,
'old_default' => $old_default,
'new_default' => $new_default,
'group_count' => count($plan),
'affected' => count($affected),
'redirects' => count($map),
];
}
/**
* After a default-language switch, 301 old URLs that would now 404 (the old
* suffixed slugs) to their new location. Only runs on a 404 and only when a
* redirect map exists, so there's no cost on normal requests.
*/
public function maybe_redirect_after_lang_switch() {
if (is_admin() || !$this->is_active() || !is_404()) {
return;
}
$map = get_option('aitrfoel_lang_switch_redirects', []);
if (empty($map) || !is_array($map)) {
return;
}
$req = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
$path = (string) wp_parse_url($req, PHP_URL_PATH);
$home_path = rtrim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
if ($home_path !== '' && strpos($path, $home_path) === 0) {
$path = substr($path, strlen($home_path));
}
$path = '/' . trim($path, '/') . '/';
if (isset($map[$path])) {
wp_safe_redirect(home_url($map[$path]), 301);
exit;
}
}
public function set_default_language($code) {
$languages = $this->get_stored_languages();
$found = false;
foreach ($languages as &$lang) {
$lang['is_default'] = ($lang['code'] === $code);
if ($lang['code'] === $code) {
$found = true;
}
}
if (!$found) {
return false;
}
update_option(self::OPTION_LANGUAGES, $languages);
update_option(self::OPTION_DEFAULT_LANG, $code);
$this->cached_languages = null;
return true;
}
/**
* Get languages from wp_options (raw stored format)
* @return array
*/
private function get_stored_languages() {
if ($this->cached_languages !== null) {
return $this->cached_languages;
}
$this->cached_languages = get_option(self::OPTION_LANGUAGES, []);
return $this->cached_languages;
}
/**
* Enable the built-in language system
*/
public function enable() {
update_option(self::OPTION_ENABLED, true);
$this->maybe_create_table();
}
/**
* Disable the built-in language system
*/
public function disable() {
update_option(self::OPTION_ENABLED, false);
}
/**
* Check if built-in system is explicitly enabled
* @return bool
*/
public static function is_enabled() {
return (bool) get_option(self::OPTION_ENABLED, false);
}
// =========================================================================
// MULTILINGUAL INTERFACE IMPLEMENTATION
// =========================================================================
/**
* @inheritDoc
*/
public function get_plugin_name() {
return 'Meep Translator Built-in';
}
/**
* Check if the current request is a frontend page load or a frontend-facing AJAX/REST request.
*
* @return bool
*/
public static function is_frontend_or_ajax() {
return !is_admin()
|| (defined('DOING_AJAX') && DOING_AJAX)
|| (function_exists('wp_doing_ajax') && wp_doing_ajax())
|| (defined('REST_REQUEST') && REST_REQUEST)
|| isset($_GET['wc-ajax']);
}
/**
* @inheritDoc
*/
public function is_active() {
return self::is_enabled() && !empty($this->get_stored_languages());
}
/**
* @inheritDoc
*/
public function get_available_languages() {
$stored = $this->get_stored_languages();
$languages = [];
foreach ($stored as $lang_data) {
$obj = new stdClass();
$obj->slug = $lang_data['code'];
$obj->code = $lang_data['code'];
$obj->name = $lang_data['name'];
$obj->native_name = $lang_data['native_name'] ?? $lang_data['name'];
$obj->is_default = !empty($lang_data['is_default']);
$obj->flag = ''; // Flag URL can be added later
$languages[$lang_data['code']] = $obj;
}
return $languages;
}
/**
* @inheritDoc
*/
public function get_default_language() {
$default = get_option(self::OPTION_DEFAULT_LANG, null);
if ($default) {
return $default;
}
// Fallback: find from languages array
$languages = $this->get_stored_languages();
foreach ($languages as $lang) {
if (!empty($lang['is_default'])) {
return $lang['code'];
}
}
// Fallback: first language
if (!empty($languages)) {
return $languages[0]['code'];
}
return null;
}
/**
* @inheritDoc
*/
public function get_post_language($post_id) {
if (!taxonomy_exists(self::TAXONOMY)) {
$this->register_taxonomy();
}
$terms = wp_get_object_terms($post_id, self::TAXONOMY, ['fields' => 'slugs']);
if (!is_wp_error($terms) && !empty($terms)) {
return $terms[0];
}
// Fallback: check translation groups DB table (handles products or
// posts whose taxonomy term was not set due to earlier bugs)
global $wpdb;
$table = $this->get_table_name();
$lang = $wpdb->get_var($wpdb->prepare(
"SELECT language_code FROM {$table} WHERE post_id = %d LIMIT 1",
$post_id
));
if ($lang) {
// Auto-heal: set taxonomy term for future lookups
$this->set_post_language($post_id, $lang);
return $lang;
}
// Fallback auto-heal: check _aitrfoel_translated_from meta (format: "{source_id}_{target_code}")
$translated_from = get_post_meta($post_id, '_aitrfoel_translated_from', true);
if (is_string($translated_from) && strpos($translated_from, '_') !== false) {
$parts = explode('_', $translated_from, 2);
$meta_lang = $parts[1] ?? '';
if ($meta_lang && $this->language_exists($meta_lang)) {
$source_id = (int) $parts[0];
$this->set_post_language($post_id, $meta_lang);
if ($source_id > 0) {
$this->setup_translation_relationship($source_id, $post_id, $meta_lang);
}
return $meta_lang;
}
}
// Posts without an explicit language are assumed to be in the default language
return $this->get_default_language();
}
/**
* @inheritDoc
*/
public function set_post_language($post_id, $lang_code) {
// Ensure the term exists
if (!term_exists($lang_code, self::TAXONOMY)) {
$lang_name = $lang_code;
$languages = $this->get_stored_languages();
foreach ($languages as $lang) {
if ($lang['code'] === $lang_code) {
$lang_name = $lang['name'];
break;
}
}
wp_insert_term($lang_code, self::TAXONOMY, ['slug' => $lang_code, 'description' => $lang_name]);
}
$result = wp_set_object_terms($post_id, $lang_code, self::TAXONOMY);
return !is_wp_error($result);
}
/**
* @inheritDoc
*/
public function get_post_translations($post_id) {
$post_id = (int) $post_id;
// Per-request memo: this is called many times per page (menus, permalinks,
// product/link localization, dedupe), so avoid re-running 2 queries each time.
if (isset(self::$translations_memo[$post_id])) {
return self::$translations_memo[$post_id];
}
global $wpdb;
$table = $this->get_table_name();
// Get the group_id for this post
$group_id = $this->get_post_group_id($post_id);
if (!$group_id) {
// If no group, return just this post with its language
$lang = $this->get_post_language($post_id);
$result = $lang ? [$lang => $post_id] : [];
self::$translations_memo[$post_id] = $result;
return $result;
}
// Get all posts in this group
$results = $wpdb->get_results($wpdb->prepare(
"SELECT post_id, language_code FROM {$table} WHERE group_id = %d",
$group_id
));
$translations = [];
foreach ($results as $row) {
// Verify the post still exists and is not trashed/deleted
$post_status = get_post_status($row->post_id);
if ($post_status && $post_status !== 'trash') {
$translations[$row->language_code] = intval($row->post_id);
}
}
// Memoize for every member of the group, since they share translations.
foreach ($translations as $member_id) {
self::$translations_memo[(int) $member_id] = $translations;
}
self::$translations_memo[$post_id] = $translations;
return $translations;
}
/**
* Clear the per-request translation memo. Call after any change to the
* translation-group table so stale relationships aren't served.
*/
public function flush_translations_memo() {
self::$translations_memo = [];
}
/**
* @inheritDoc
*/
public function setup_translation_relationship($original_post_id, $translated_post_id, $target_language) {
global $wpdb;
$table = $this->get_table_name();
// Normalize language code
$lang_code = $this->normalize_language_code($target_language);
if (!$lang_code) {
if (class_exists('AITRFOEL_Logger')) {
AITRFOEL_Logger::log("Cannot normalize language: {$target_language}", 'error', $original_post_id);
}
return new WP_Error('invalid_language', "Cannot normalize language: {$target_language}");
}
// Verify language exists in our system
if (!$this->language_exists($lang_code)) {
if (class_exists('AITRFOEL_Logger')) {
AITRFOEL_Logger::log("Language '{$lang_code}' not configured in built-in system.", 'error', $original_post_id);
}
return new WP_Error('language_not_found', "Language '{$lang_code}' not configured.");
}
// Get original's language, set default if missing
$original_lang = $this->get_post_language($original_post_id);
if (!$original_lang) {
$default_lang = $this->get_default_language();
if ($default_lang) {
$this->set_post_language($original_post_id, $default_lang);
$original_lang = $default_lang;
} else {
return new WP_Error('no_default_language', 'Cannot determine default language.');
}
}
// Set language on translated post
$this->set_post_language($translated_post_id, $lang_code);
// Get or create translation group
$group_id = $this->get_post_group_id($original_post_id);
if (!$group_id) {
$group_id = $this->get_next_group_id();
// Add original to group
$wpdb->replace($table, [
'group_id' => $group_id,
'post_id' => $original_post_id,
'language_code' => $original_lang,
], ['%d', '%d', '%s']);
}
// Add/update translated post in the group
// First remove any existing entry for this post
$wpdb->delete($table, ['post_id' => $translated_post_id], ['%d']);
// Also remove any existing entry for this language in this group (replacing previous translation)
$wpdb->delete($table, [
'group_id' => $group_id,
'language_code' => $lang_code,
], ['%d', '%s']);
// Insert the new relationship
$inserted = $wpdb->insert($table, [
'group_id' => $group_id,
'post_id' => $translated_post_id,
'language_code' => $lang_code,
], ['%d', '%d', '%s']);
if ($inserted === false) {
if (class_exists('AITRFOEL_Logger')) {
AITRFOEL_Logger::log("Failed to insert translation group entry: " . $wpdb->last_error, 'error', $original_post_id);
}
return new WP_Error('db_error', 'Failed to save translation relationship.');
}
// Relationship changed — drop the per-request memo so lookups don't go stale.
$this->flush_translations_memo();
// First time a custom post type gets a translation, flush rewrite rules once so
// the language-prefixed CPT permalink (e.g. /en/realisaties/slug/) resolves
// instead of 404ing. Guarded to run at most once per post type.
$this->maybe_flush_rules_for_new_cpt_translation($translated_post_id);
if (class_exists('AITRFOEL_Logger')) {
AITRFOEL_Logger::log(
sprintf('Built-in relationship created: post %d (%s) → post %d (%s), group %d',
$original_post_id, $original_lang, $translated_post_id, $lang_code, $group_id
),
'success',
$original_post_id,
$translated_post_id
);
}
return true;
}
/**
* Flush rewrite rules the first time a translation is created for a given custom
* post type, so its language-prefixed single permalink starts resolving instead of
* 404ing. Pages, posts and products already have working rules; only public,
* non-builtin CPTs need this. Runs at most once per post type (tracked in an
* option), and only when our prefixed rules are registered this request.
*
* @param int $translated_post_id
*/
private function maybe_flush_rules_for_new_cpt_translation($translated_post_id) {
$post_type = get_post_type($translated_post_id);
if (!$post_type || in_array($post_type, ['page', 'post', 'product'], true)) {
return;
}
$obj = get_post_type_object($post_type);
if (!$obj || empty($obj->public) || !empty($obj->_builtin)) {
return;
}
$flushed = get_option('aitrfoel_flushed_translation_cpts', []);
if (!is_array($flushed)) {
$flushed = [];
}
if (in_array($post_type, $flushed, true)) {
return; // already flushed for this CPT
}
$flushed[] = $post_type;
update_option('aitrfoel_flushed_translation_cpts', $flushed);
// Reset our rules version so add_rewrite_rules() also schedules its own flush on
// the next request (belt-and-suspenders if the immediate flush below runs before
// some plugin registered its rules this request).
update_option('aitrfoel_rewrite_rules_version', '');
// Our prefixed CPT rules are registered on init:25 every request, so by the time
// a translation is created they are in memory — persist them now.
flush_rewrite_rules(false);
if (class_exists('AITRFOEL_Logger')) {
AITRFOEL_Logger::log(
sprintf('Flushed rewrite rules for first translation of CPT "%s".', $post_type),
'info', $translated_post_id
);
}
}
/**
* @inheritDoc
*/
public function get_translation_status($post_id) {
$status = [];
$translations = $this->get_post_translations($post_id);
$available_languages = $this->get_available_languages();
foreach ($available_languages as $lang) {
$status[$lang->slug] = [
'language_name' => $lang->name,
'language_code' => $lang->slug,
'translated' => isset($translations[$lang->slug]),
'post_id' => $translations[$lang->slug] ?? null,
'edit_url' => isset($translations[$lang->slug]) ? get_edit_post_link($translations[$lang->slug]) : null,
'view_url' => isset($translations[$lang->slug]) ? get_permalink($translations[$lang->slug]) : null,
'status' => isset($translations[$lang->slug]) ? get_post_status($translations[$lang->slug]) : null,
];
}
return $status;
}
/**
* @inheritDoc
*/
public function language_exists($lang_code) {
$languages = $this->get_stored_languages();
foreach ($languages as $lang) {
if ($lang['code'] === $lang_code || $lang['slug'] === $lang_code) {
return true;
}
}
return false;
}
/**
* Reconcile translation links so the two linking mechanisms agree:
* the `_aitrfoel_source_page` post meta and the translation-groups table
* (plus the `aitrfoel_language` term). Fixes "orphaned" translations that
* appear as their own source in the dashboard and break the cart/checkout
* page swap, by rebuilding consistent groups from whichever signal exists.
*
* @param bool $apply When false, only report what would change (dry run).
* @return array Report with counts and any items needing manual review.
*/
public function repair_translation_links($apply = true) {
global $wpdb;
$table = $this->get_table_name();
$default_lang = $this->get_default_language();
$report = [
'applied' => (bool) $apply,
'components_repaired' => 0,
'group_rows_written' => 0,
'metas_written' => 0,
'language_terms_set' => 0,
'parents_remapped' => 0,
'group_langs_fixed' => 0,
'conflicts' => [],
'orphans' => [],
];
// 1. Source-page meta links: translation_id => source_id.
$meta_source = [];
$meta_rows = $wpdb->get_results(
"SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_aitrfoel_source_page'"
);
foreach ($meta_rows as $r) {
$tid = (int) $r->post_id;
$sid = (int) $r->meta_value;
if ($sid > 0 && $sid !== $tid) {
$meta_source[$tid] = $sid;
}
}
// 2. Existing group memberships: group_id => [post_id, ...].
$groups = [];
$group_rows = $wpdb->get_results("SELECT group_id, post_id FROM {$table}");
foreach ($group_rows as $r) {
$groups[(int) $r->group_id][] = (int) $r->post_id;
}
// 3. Union-find over both signals.
$parent = [];
$add = function ($x) use (&$parent) {
if (!isset($parent[$x])) {
$parent[$x] = $x;
}
};
$find = function ($x) use (&$parent) {
while ($parent[$x] !== $x) {
$parent[$x] = $parent[$parent[$x]];
$x = $parent[$x];
}
return $x;
};
$union = function ($a, $b) use (&$parent, $add, $find) {
$add($a);
$add($b);
$ra = $find($a);
$rb = $find($b);
if ($ra !== $rb) {
$parent[$ra] = $rb;
}
};
foreach ($meta_source as $tid => $sid) {
$union($tid, $sid);
}
foreach ($groups as $members) {
$first = null;
foreach ($members as $m) {
$add($m);
if ($first === null) {
$first = $m;
} else {
$union($first, $m);
}
}
}
// 4. Build components and repair each.
$components = [];
foreach (array_keys($parent) as $node) {
$components[$find($node)][] = $node;
}
foreach ($components as $members) {
$members = array_values(array_unique($members));
if (count($members) < 2) {
continue;
}
// Skip members that no longer exist / are trashed.
$members = array_values(array_filter($members, function ($m) {
$status = get_post_status($m);
return $status && $status !== 'trash' && $status !== 'auto-draft';
}));
if (count($members) < 2) {
continue;
}
$member_lang = [];
foreach ($members as $m) {
$member_lang[$m] = $this->get_post_language($m);
}
// Source = default-language original (no source meta), else no-meta member, else oldest.
$source_id = 0;
foreach ($members as $m) {
if ($member_lang[$m] === $default_lang && empty($meta_source[$m])) {
$source_id = $m;
break;
}
}
if (!$source_id) {
foreach ($members as $m) {
if (empty($meta_source[$m])) {
$source_id = $m;
break;
}
}
}
if (!$source_id) {
$sorted = $members;
usort($sorted, function ($a, $b) {
return strcmp((string) get_post_field('post_date', $a), (string) get_post_field('post_date', $b));
});
$source_id = $sorted[0];
}
$repaired_any = false;
$source_group = $this->get_post_group_id($source_id);
// Source missing its (default) language term?
if (!$this->get_post_language($source_id)) {
$report['language_terms_set']++;
$repaired_any = true;
if ($apply) {
$this->set_post_language($source_id, $default_lang);
$source_group = $this->get_post_group_id($source_id);
}
}
// A genuine conflict is two members sharing the SAME language — not a
// member that merely happens to be in the default language. After the
// default language is switched, the translations legitimately become the
// default-language version, so comparing to $default_lang would falsely
// flag every one of them. Compare to the source's own language instead.
$source_lang = isset($member_lang[$source_id]) && $member_lang[$source_id]
? $member_lang[$source_id]
: $default_lang;
foreach ($members as $m) {
if ($m === $source_id) {
continue;
}
$lang = $member_lang[$m];
if (!$lang || $lang === $source_lang) {
// Check if _aitrfoel_translated_from specifies a different language
$translated_from = get_post_meta($m, '_aitrfoel_translated_from', true);
if (is_string($translated_from) && strpos($translated_from, '_') !== false) {
$parts = explode('_', $translated_from, 2);
$meta_lang = $parts[1] ?? '';
if ($meta_lang && $meta_lang !== $source_lang && $this->language_exists($meta_lang)) {
$lang = $meta_lang;
$member_lang[$m] = $meta_lang;
if ($apply) {
$this->set_post_language($m, $meta_lang);
}
}
}
}
if (!$lang || $lang === $source_lang) {
$report['conflicts'][] = [
'post_id' => $m,
'title' => get_the_title($m),
'reason' => 'Shares the source language — genuine duplicate, cannot auto-link.',
];
continue;
}
// Count/fix only genuine discrepancies.
$m_group = $this->get_post_group_id($m);
$needs_group = ($source_group === null) || ($m_group === null) || ((int) $m_group !== (int) $source_group);
$needs_meta = ((int) get_post_meta($m, '_aitrfoel_source_page', true) !== $source_id);
if ($needs_group) {
$report['group_rows_written']++;
$repaired_any = true;
if ($apply) {
$res = $this->setup_translation_relationship($source_id, $m, $lang);
if (!is_wp_error($res) && $source_group === null) {
$source_group = $this->get_post_group_id($source_id);
}
}
}
if ($needs_meta) {
$report['metas_written']++;
$repaired_any = true;
if ($apply) {
update_post_meta($m, '_aitrfoel_source_page', $source_id);
}
}
}
if ($repaired_any) {
$report['components_repaired']++;
}
}
// 5. Orphans: posts with a non-default language term but no link anywhere.
$orphan_rows = $wpdb->get_results($wpdb->prepare(
"SELECT tr.object_id AS post_id, t.slug AS lang
FROM {$wpdb->term_relationships} tr
JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = %s
JOIN {$wpdb->terms} t ON t.term_id = tt.term_id",
self::TAXONOMY
));
foreach ($orphan_rows as $r) {
$pid = (int) $r->post_id;
if ($r->lang === $default_lang || isset($parent[$pid])) {
continue; // default language, or already linked in a component.
}
$status = get_post_status($pid);
if (!$status || $status === 'trash' || $status === 'auto-draft') {
continue;
}
if (count($report['orphans']) < 100) {
$report['orphans'][] = [
'post_id' => $pid,
'title' => get_the_title($pid),
'lang' => strtoupper($r->lang),
'type' => get_post_type($pid),
];
}
}
// 6. Diagnostic: report the link state of the WooCommerce cart/checkout
// pages specifically, since their swap depends on it.
$report['wc_pages'] = [];
if (class_exists('WooCommerce')) {
$wc_page_opts = [
'cart' => 'woocommerce_cart_page_id',
'checkout' => 'woocommerce_checkout_page_id',
];
foreach ($wc_page_opts as $label => $opt) {
// Read the real configured page ID (bypass our pre_option swap).
remove_filter('pre_option_' . $opt, [$this, 'filter_translated_page_option'], 10);
$pid = (int) get_option($opt);
add_filter('pre_option_' . $opt, [$this, 'filter_translated_page_option'], 10, 2);
if ($pid <= 0) {
continue;
}
$tr = $this->get_post_translations($pid);
$tr_str = [];
foreach ($tr as $lc => $tid) {
$tr_str[] = strtoupper($lc) . '#' . (int) $tid;
}
$report['wc_pages'][] = [
'label' => $label,
'page_id' => $pid,
'title' => get_the_title($pid),
'lang' => strtoupper($this->get_post_language($pid) ?: '?'),
'translations' => $tr_str,
];
}
}
// 7b. Reconcile the group table's language_code with each post's authoritative
// language term. get_post_translations() reads the table while routing and
// permalinks read the term — if they disagree, the switcher and the default
// migration build wrong-language URLs. The term is authoritative (it drives
// rendering), so any out-of-sync table row is corrected to match it.
$gl_rows = $wpdb->get_results("SELECT post_id, language_code FROM {$table}");
foreach ((array) $gl_rows as $gr) {
$pid = (int) $gr->post_id;
$table_lng = (string) $gr->language_code;
$term_lng = $this->get_post_language($pid); // term-first (auto-heals if missing)
if ($term_lng && $term_lng !== $table_lng) {
$report['group_langs_fixed']++;
if ($apply) {
$wpdb->update($table, ['language_code' => $term_lng], ['post_id' => $pid]);
}
}
}
// 8. Parent hierarchy: re-attach translated children to the translated parent.
// A translated child whose post_parent is still the source-language parent
// is moved under the parent's translation in the child's own language, so
// the hierarchy and URL (/de/eltern/kind/) follow the translation.
$children = $wpdb->get_results(
"SELECT ID, post_parent FROM {$wpdb->posts}
WHERE post_parent > 0 AND post_status NOT IN ('trash', 'auto-draft')"
);
foreach ((array) $children as $c) {
$child_id = (int) $c->ID;
$parent_id = (int) $c->post_parent;
if (!$parent_id) {
continue;
}
$child_lang = $this->get_post_language($child_id);
$parent_lang = $this->get_post_language($parent_id);
// Only act on a translated child whose parent is in a different language.
if (!$child_lang || $child_lang === $default_lang || $parent_lang === $child_lang) {
continue;
}
$parent_translations = $this->get_post_translations($parent_id);
if (empty($parent_translations[$child_lang])) {
continue; // parent not translated into this language yet
}
$new_parent = (int) $parent_translations[$child_lang];
if ($new_parent === $parent_id || $new_parent === $child_id) {
continue;
}
$report['parents_remapped']++;
if ($apply) {
wp_update_post(['ID' => $child_id, 'post_parent' => $new_parent]);
}
}
$this->flush_translations_memo();
return $report;
}
/**
* Rewrite [elementor-template id="X"] shortcodes in non-default-language posts
* so each id references the per-language translated template. Fixes legacy
* translated posts that still embed the source-language template id.
*
* Split out from repair_translation_links() so site owners can run it on its
* own from Tools. Returns a report compatible with the Repair Links UI keys.
*
* @param bool $apply When false, only previews (dry run).
* @return array
*/
public function repair_template_shortcodes($apply = true) {
global $wpdb;
$report = [
'applied' => (bool) $apply,
'shortcode_template_ids_fixed' => 0,
'shortcode_template_post_ids' => [],
];
$default_lang = $this->get_default_language();
$shortcode_rows = $wpdb->get_results(
"SELECT ID FROM {$wpdb->posts}
WHERE post_status NOT IN ('trash','auto-draft','inherit')
AND post_content LIKE '%[elementor-template%'"
);
if (!empty($shortcode_rows) && class_exists('AITRFOEL_Translation_Engine')) {
$engine = AITRFOEL_Translation_Engine::get_instance();
foreach ($shortcode_rows as $row) {
$pid = (int) $row->ID;
$lang = $this->get_post_language($pid);
if (!$lang || $lang === $default_lang) {
continue; // only rewrite translated posts
}
$post = get_post($pid);
if (!$post || !is_string($post->post_content) || $post->post_content === '') {
continue;
}
$rewritten = $engine->rewrite_elementor_template_shortcode_ids($post->post_content, $lang);
if ($rewritten === $post->post_content) {
continue; // nothing to change for this post
}
$report['shortcode_template_ids_fixed']++;
if (count($report['shortcode_template_post_ids']) < 100) {
$report['shortcode_template_post_ids'][] = [
'post_id' => $pid,
'title' => get_the_title($pid),
'lang' => strtoupper($lang),
];
}
if ($apply) {
wp_update_post([
'ID' => $pid,
'post_content' => $rewritten,
]);
}
}
}
$this->flush_translations_memo();
return $report;
}
/**
* Loops through all translated posts and re-localizes internal links inside their Elementor data.
*
* @param bool $apply When false, only previews (dry run).
* @return array
*/
public function repair_content_links($apply = true) {
global $wpdb;
$report = [
'applied' => (bool) $apply,
'pages_scanned' => 0,
'pages_repaired' => 0,
'repaired_pages_info' => [],
];
// Fetch all posts that have _aitrfoel_source_page meta set (which designates translated posts)
$rows = $wpdb->get_results(
"SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_aitrfoel_source_page'"
);
if (!empty($rows) && class_exists('AITRFOEL_Translation_Engine')) {
$engine = AITRFOEL_Translation_Engine::get_instance();
foreach ($rows as $row) {
$pid = (int) $row->post_id;
$post = get_post($pid);
if (!$post || $post->post_status === 'trash' || $post->post_status === 'auto-draft' || $post->post_status === 'inherit') {
continue; // Skip trashed/revision/auto-draft/deleted posts
}
$lang = $this->get_post_language($pid);
if (!$lang) {
continue;
}
$report['pages_scanned']++;
$modified = false;
// 1. Repair Elementor JSON Data if exists
$data = null;
$original_data = null;
$elementor_data_raw = get_post_meta($pid, '_elementor_data', true);
if (!empty($elementor_data_raw)) {
$data = json_decode($elementor_data_raw, true);
if (!$data) {
$data = json_decode(wp_unslash($elementor_data_raw), true);
}
if ($data) {
$original_data = $data;
$engine->localize_internal_links($data, $lang);
if ($data !== $original_data) {
$modified = true;
}
}
}
// 2. Repair Hrefs in standard post_content
$original_content = $post->post_content;
$repaired_content = $engine->localize_hrefs_in_html($original_content, $lang);
if ($repaired_content !== $original_content) {
$modified = true;
}
if (!$modified) {
continue; // No changes needed
}
$report['pages_repaired']++;
if (count($report['repaired_pages_info']) < 100) {
$report['repaired_pages_info'][] = [
'post_id' => $pid,
'title' => $post->post_title,
'lang' => strtoupper($lang),
];
}
if ($apply) {
// Update Elementor Data
if ($data && $data !== $original_data) {
$json_value = wp_slash(wp_json_encode($data));
update_post_meta($pid, '_elementor_data', $json_value);
}
// Update standard post_content
if ($repaired_content !== $original_content) {
$wpdb->update(
$wpdb->posts,
['post_content' => $repaired_content],
['ID' => $pid]
);
clean_post_cache($pid);
}
// Clear Elementor CSS cache for this page so it gets re-generated
if ($data && class_exists('\Elementor\Core\Files\CSS\Post')) {
$css_file = new \Elementor\Core\Files\CSS\Post($pid);
$css_file->delete();
}
}
}
}
return $report;
}
/**
* Constrain WordPress's adjacent-post query (Previous/Next post navigation) to
* the language of the post being viewed. Excludes any candidate that carries a
* different language term, so neighbours are always same-language. Posts with no
* language term (assumed default language) stay eligible for default-language
* navigation.
*
* Hooked on get_previous_post_where / get_next_post_where.
*
* @param string $where
* @param bool $in_same_term
* @param array $excluded_terms
* @param string $taxonomy
* @param WP_Post $post
* @return string
*/
public function filter_adjacent_post_where($where, $in_same_term = false, $excluded_terms = [], $taxonomy = '', $post = null) {
if (!self::is_enabled() || !($post instanceof WP_Post)) {
return $where;
}
$other_ttids = $this->get_other_language_ttids($post->ID);
if (empty($other_ttids)) {
return $where;
}
global $wpdb;
$ids = implode(',', array_map('intval', $other_ttids));
// WordPress aliases the posts table as `p` in get_adjacent_post().
$where .= " AND p.ID NOT IN (SELECT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id IN ($ids))";
return $where;
}
/**
* Term-taxonomy ids for every language EXCEPT the language of the given post.
* Used to exclude other-language posts from adjacent-post navigation.
*
* @param int $post_id
* @return int[]
*/
private function get_other_language_ttids($post_id) {
$current = $this->get_post_language($post_id); // slug, or default
$terms = get_terms([
'taxonomy' => self::TAXONOMY,
'hide_empty' => false,
]);
if (is_wp_error($terms) || count($terms) < 2) {
return [];
}
$others = [];
foreach ($terms as $term) {
if ($term->slug === $current) {
continue;
}
$others[] = (int) $term->term_taxonomy_id;
}
return $others;
}
/**
* Scope a JetEngine listing / query-builder WP_Query to the current language by
* excluding posts tagged with any OTHER language. Posts in the current language
* (or untagged, assumed default) stay visible. Only applied to post types that use
* our language taxonomy, so non-translatable listings are untouched.
*
* @param array $args WP_Query args.
* @return array
*/
public function filter_jetengine_query_lang($args) {
if (is_admin() && !wp_doing_ajax()) {
return $args;
}
if (!$this->is_active() || !is_array($args)) {
return $args;
}
$current = $this->get_current_language();
if (!$current) {
return $args;
}
// Need at least two languages for collapsing to matter.
if (count($this->get_stored_languages()) < 2) {
return $args;
}
// Only act on post types that use our language taxonomy.
$post_types = isset($args['post_type']) ? (array) $args['post_type'] : [];
$applies = empty($post_types); // unknown type → best-effort
foreach ($post_types as $pt) {
if ($pt && in_array(self::TAXONOMY, get_object_taxonomies($pt), true)) {
$applies = true;
break;
}
}
if (!$applies) {
return $args;
}
// Filter queries strictly by language using taxonomy query.
// This ensures correct post counts (found_posts) and fixes pagination.
if (!isset($args['tax_query'])) {
$args['tax_query'] = [];
}
$args['tax_query'][] = [
'taxonomy' => self::TAXONOMY,
'field' => 'slug',
'terms' => [$current],
'operator' => 'IN',
];
// Keep the the_posts collapsing filter as a fallback for custom queries or grids
// where SQL tax_query is bypassed (removes cross-language duplicates).
add_filter('the_posts', [$this, 'localize_jetengine_listing_posts'], 20, 2);
return $args;
}
/**
* Scope frontend queries (WP_Query loops) dynamically by language using tax_query.
* This acts as a global catch-all to prevent third-party filtering/pagination
* plugins (like JetSmartFilters) from pulling posts of different languages
* during custom AJAX pagination and grid updates.
*
* @param WP_Query $query The query object.
*/
public function filter_pre_get_posts($query) {
if (is_admin() && !wp_doing_ajax()) {
return;
}
if (!$this->is_active()) {
return;
}
$current = $this->get_current_language();
if (!$current) {
return;
}
if (count($this->get_stored_languages()) < 2) {
return;
}
// Only filter queries for post types that use our language taxonomy
$post_type = $query->get('post_type');
if (empty($post_type)) {
return;
}
$post_types = (array) $post_type;
// Bypass media attachments or query-attachments AJAX requests
if (in_array('attachment', $post_types, true) || (isset($_REQUEST['action']) && $_REQUEST['action'] === 'query-attachments')) {
return;
}
// Template / theme builder post types must NEVER be restricted by language tax_query.
// Elementor Pro Theme Builder, Header Footer Builder, and JetThemeCore select templates
// dynamically based on display conditions and swap them at render time via location/builder filters.
$template_types = apply_filters('aitrfoel_template_post_types', [
'elementor_library',
'elementor-hf',
'jet-theme-core',
'jet-engine',
'e-floating-buttons',
'elementor_snippet',
'wp_block',
'wp_template',
'wp_template_part',
'wp_navigation',
]);
foreach ($post_types as $pt) {
if (in_array($pt, $template_types, true)) {
return;
}
}
$applies = false;
foreach ($post_types as $pt) {
if ($pt && in_array(self::TAXONOMY, get_object_taxonomies($pt), true)) {
$applies = true;
break;
}
}
if (!$applies) {
return;
}
// Bypass for single post lookups by ID, explicit post__in lists, or menu items
if ($query->get('p') || $query->get('page_id') || $query->get('post__in') || $query->get('post_type') === 'nav_menu_item') {
return;
}
// Add tax_query for language
$tax_query = $query->get('tax_query');
if (!is_array($tax_query)) {
$tax_query = [];
}
// Check if language taxonomy query is already present to avoid duplicates
$has_lang_query = false;
foreach ($tax_query as $tq) {
if (is_array($tq) && isset($tq['taxonomy']) && $tq['taxonomy'] === self::TAXONOMY) {
$has_lang_query = true;
break;
}
}
if (!$has_lang_query) {
$default_lang = $this->get_default_language();
if ($current === $default_lang) {
// In default language, exclude non-default language posts (NOT IN)
// so that legacy/untranslated posts without explicit taxonomy terms remain visible.
$non_default_langs = [];
foreach ($this->get_stored_languages() as $l) {
if ($l['code'] !== $default_lang) {
$non_default_langs[] = $l['code'];
}
}
if (!empty($non_default_langs)) {
$tax_query[] = [
'taxonomy' => self::TAXONOMY,
'field' => 'slug',
'terms' => $non_default_langs,
'operator' => 'NOT IN',
'include_children' => false,
];
$query->set('tax_query', $tax_query);
}
} else {
// In non-default language, only include posts tagged with the current language.
$tax_query[] = [
'taxonomy' => self::TAXONOMY,
'field' => 'slug',
'terms' => [$current],
'operator' => 'IN',
'include_children' => false,
];
$query->set('tax_query', $tax_query);
}
}
}
/**
* Collapse a JetEngine listing result set to one post per translation group:
* the published current-language translation when it exists, otherwise the
* original (fallback) — so the grid never shows duplicates and is never empty
* just because a translation is still a draft. Other-language items with no
* current-language version are dropped. Runs once per JetEngine query.
*
* @param array $posts
* @param WP_Query $query
* @return array
*/
public function localize_jetengine_listing_posts($posts, $query) {
remove_filter('the_posts', [$this, 'localize_jetengine_listing_posts'], 20);
if (empty($posts) || !is_array($posts)) {
return $posts;
}
$current = $this->get_current_language();
$default = $this->get_default_language();
if (!$current) {
return $posts;
}
// Index the raw result by ID so we can read each group's source post date
// without extra queries (the unfiltered query returns source + translations).
$by_id = [];
foreach ($posts as $p) {
if (is_object($p) && !empty($p->ID)) {
$by_id[(int) $p->ID] = $p;
}
}
// When the listing is date-ordered (the default), translation copies carry
// their own creation date, which scrambles the order once we swap them in.
// Re-sort by each group's SOURCE date so the listing keeps the original
// order regardless of when translations were created. Custom orderings
// (title, menu_order, meta) are left untouched.
$orderby = is_object($query) ? $query->get('orderby') : '';
$order = (is_object($query) && strtoupper((string) $query->get('order')) === 'ASC') ? 'ASC' : 'DESC';
$sort_by_date = ($orderby === '' || $orderby === 'date' || $orderby === 'post_date');
$out = [];
$seen_groups = [];
foreach ($posts as $p) {
if (!is_object($p) || empty($p->ID)) {
$out[] = ['post' => $p, 'date' => ''];
continue;
}
$translations = $this->get_post_translations($p->ID);
// Group identity = the default-language member (or this post if none).
$group_key = isset($translations[$default]) ? (int) $translations[$default] : (int) $p->ID;
if (isset($seen_groups[$group_key])) {
continue; // group already represented
}
$chosen = null;
if (isset($translations[$current]) && get_post_status($translations[$current]) === 'publish') {
$chosen = get_post((int) $translations[$current]); // current language
} else {
$lang = $this->get_post_language($p->ID);
// Fallback to the original/source (or an untagged post); skip stray
// other-language items that have no current-language version.
if (!$lang || $lang === $default || $lang === $current) {
$chosen = $p;
}
}
if ($chosen) {
$seen_groups[$group_key] = true;
// Sort key = the source (default-language) post's date when available,
// so all translations of a group sort to the source's position.
$source_date = isset($by_id[$group_key]) ? $by_id[$group_key]->post_date : $chosen->post_date;
$out[] = ['post' => $chosen, 'date' => (string) $source_date];
}
}
if ($sort_by_date) {
usort($out, function ($a, $b) use ($order) {
$cmp = strcmp($a['date'], $b['date']);
return $order === 'ASC' ? $cmp : -$cmp;
});
}
return array_map(function ($row) { return $row['post']; }, $out);
}
/**
* Localize an Astra header-builder custom button link (theme_mod value) to the
* current language's translation of the linked page. Accepts both Astra's array
* shape (['url' => ...]) and a plain URL string.
*
* @param mixed $value
* @return mixed
*/
/**
* Start buffering the theme header so its internal links can be localized.
* Only buffers on a non-default language.
*/
public function start_header_link_localization() {
if ($this->header_ob_active || !$this->is_active()) {
return;
}
$current = $this->get_current_language();
if ($current && $current !== $this->get_default_language()) {
ob_start();
$this->header_ob_active = true;
}
}
/**
* Flush the buffered header through the internal-link localizer, so theme-rendered
* links (Astra header buttons, custom HTML, etc.) point at the current-language
* translations.
*/
public function end_header_link_localization() {
if (!$this->header_ob_active) {
return;
}
$this->header_ob_active = false;
$html = ob_get_clean();
echo $this->localize_internal_links($html);
}
public function localize_astra_button_link($value) {
if (is_admin() || !$this->is_active()) {
return $value;
}
if (is_array($value) && !empty($value['url']) && is_string($value['url'])) {
$localized = $this->localize_single_internal_url($value['url']);
if ($localized) {
$value['url'] = $localized;
}
return $value;
}
if (is_string($value) && $value !== '') {
$localized = $this->localize_single_internal_url($value);
if ($localized) {
return $localized;
}
}
return $value;
}
/**
* Resolve a single internal URL to the current-language translation's permalink,
* or return null when no swap applies (default language, external URL, no
* translation, etc.). Shared by header-button / theme-element localizers.
*
* @param string $url
* @return string|null
*/
private function localize_single_internal_url($url) {
if (!is_string($url) || $url === '') {
return null;
}
$current = $this->current_language_resolved();
$default = $this->get_default_language();
if (!$current || $current === $default) {
return null;
}
$raw_home = rtrim((string) get_option('home'), '/');
if ($raw_home === '' || strpos($url, $raw_home) !== 0) {
return null; // not an internal absolute URL
}
$post_id = $this->resolve_internal_url_to_post($url);
if (!$post_id) {
return null;
}
$translations = $this->get_post_translations($post_id);
if (isset($translations[$current]) && get_post_status($translations[$current]) === 'publish') {
$permalink = get_permalink((int) $translations[$current]);
return $permalink ?: null;
}
return null;
}
/**
* @inheritDoc
*/
public function get_language_code($our_language) {
return $this->normalize_language_code($our_language);
}
/**
* @inheritDoc
*/
public function remove_translation_relationship($post_id) {
global $wpdb;
$table = $this->get_table_name();
try {
// Remove this post from its translation group
$wpdb->delete($table, ['post_id' => $post_id], ['%d']);
// Remove language taxonomy
wp_set_object_terms($post_id, [], self::TAXONOMY);
// Relationship changed — drop the per-request memo.
$this->flush_translations_memo();
return true;
} catch (Exception $e) {
if (class_exists('AITRFOEL_Logger')) {
AITRFOEL_Logger::log(
sprintf('Failed to remove translation relationship for post %d: %s', $post_id, $e->getMessage()),
'error',
$post_id
);
}
return false;
}
}
/**
* @inheritDoc
*/
public function get_pages_translation_status() {
if (!$this->is_active()) {
return [];
}
$post_types = ['page', 'post'];
if (class_exists('WooCommerce') && get_option('aitrfoel_woocommerce_support', false)) {
$post_types[] = 'product';
}
$pages = get_posts([
'post_type' => $post_types,
'posts_per_page' => -1,
'post_status' => ['publish', 'draft'],
'orderby' => 'title',
'order' => 'ASC',
]);
$pages_status = [];
foreach ($pages as $page) {
$translation_status = $this->get_translation_status($page->ID);
$elementor_data = get_post_meta($page->ID, '_elementor_data', true);
$pages_status[] = [
'id' => $page->ID,
'title' => $page->post_title,
'status' => $page->post_status,
'edit_url' => get_edit_post_link($page->ID),
'view_url' => get_permalink($page->ID),
'translations' => $translation_status,
'has_elementor' => !empty($elementor_data),
];
}
return $pages_status;
}
/**
* @inheritDoc
*/
public function get_language_switcher_data() {
if (!$this->is_active()) {
return [];
}
$languages = $this->get_available_languages();
$default_lang = $this->get_default_language();
$current_post_id = get_queried_object_id();
$translations = $current_post_id ? $this->get_post_translations($current_post_id) : [];
$hide_untranslated = get_option('aitrfoel_switcher_hide_untranslated', false);
$switcher_data = [];
foreach ($languages as $lang) {
$has_translation = isset($translations[$lang->slug]);
// Skip untranslated languages if setting is enabled (always show default & current)
if ($hide_untranslated && $current_post_id && !$has_translation
&& $lang->slug !== $default_lang && $lang->slug !== $this->get_current_language()) {
continue;
}
$raw_home = trailingslashit(get_option('home'));
$default_home = get_option('aitrfoel_default_lang_prefix', false) ? $raw_home . $default_lang . '/' : $raw_home;
$url = ($lang->slug === $default_lang) ? $default_home : ($raw_home . $lang->slug . '/');
// If we have a translation of the current page, link to it
if ($has_translation) {
$url = $this->get_post_permalink($translations[$lang->slug]);
} elseif ($lang->slug === $default_lang && $current_post_id) {
// Switching to default language: link to the original post
// Find the default-language post in the translation group
if (isset($translations[$default_lang])) {
$url = $this->get_post_permalink($translations[$default_lang]);
} else {
// Current post might be the default-language post itself
$post_lang = $this->get_post_language($current_post_id);
if ($post_lang === $default_lang) {
$url = $this->get_post_permalink($current_post_id);
}
}
}
$switcher_data[] = [
'code' => $lang->slug,
'name' => $lang->name,
'native_name' => $lang->native_name ?? $lang->name,
'flag' => $lang->flag ?? '',
'is_default' => $lang->slug === $default_lang,
'is_current' => $lang->slug === $this->get_current_language(),
'url' => $url,
];
}
return $switcher_data;
}
/**
* Get the correct permalink for a post with language prefix
* Note: The filter_permalink/filter_page_permalink hooks already add language
* prefixes when get_permalink() is called, so we just return get_permalink().
* @inheritDoc
*/
public function get_post_permalink($post_id) {
return get_permalink($post_id);
}
// =========================================================================
// URL REWRITING & LANGUAGE DETECTION
// =========================================================================
/**
* Add rewrite rules for language prefixes
*/
public function add_rewrite_rules() {
if (!$this->is_active()) {
return;
}
$languages = $this->get_stored_languages();
$default_lang = $this->get_default_language();
$include_default_prefix = get_option('aitrfoel_default_lang_prefix', false);
$lang_codes = [];
foreach ($languages as $lang) {
if ($lang['code'] !== $default_lang || $include_default_prefix) {
$lang_codes[] = preg_quote($lang['code'], '/');
}
}
if (empty($lang_codes)) {
return;
}
$lang_pattern = implode('|', $lang_codes);
// Auto-flush rewrite rules when version changes OR when stored rules
// are missing our language patterns (can happen after AJAX setup, reinstall, etc.)
$rules_version = '1.6.0'; // Bump this when rewrite rules change
$stored_version = get_option('aitrfoel_rewrite_rules_version', '');
$needs_flush = ($stored_version !== $rules_version);
if (!$needs_flush) {
// Verify our language rules are actually present in stored rewrite rules
$stored_rules = get_option('rewrite_rules', []);
if (is_array($stored_rules)) {
$test_pattern = '^(' . $lang_pattern . ')/?$';
if (!isset($stored_rules[$test_pattern])) {
$needs_flush = true;
}
// Also verify every public custom post type has its language-prefixed
// single rule. CPTs registered after our first pass (JetEngine, etc.)
// would otherwise be missing, and the catch-all page rule would 404
// their URLs (e.g. /nl/realisaties/slug/). Auto-heal by flushing once.
if (!$needs_flush) {
$cpts_check = get_post_types(['public' => true, '_builtin' => false], 'objects');
foreach ($cpts_check as $cpt_check) {
$cpt_slug = isset($cpt_check->rewrite['slug']) ? $cpt_check->rewrite['slug'] : $cpt_check->name;
$cpt_slug = trim((string) $cpt_slug, '/');
if ($cpt_slug === '') {
continue;
}
$probe = '^(' . $lang_pattern . ')/' . preg_quote($cpt_slug, '/') . '/([^/]+)/?$';
if (!isset($stored_rules[$probe])) {
$needs_flush = true;
break;
}
}
}
}
}
if ($needs_flush) {
update_option('aitrfoel_rewrite_rules_version', $rules_version);
// Schedule flush after all rules are registered
add_action('wp_loaded', function() {
flush_rewrite_rules();
});
}
// === Rewrite rules use 'top' which adds to extra_rules_top array ===
// WordPress extra_rules_top uses FIFO: FIRST added = FIRST checked = HIGHEST priority.
// So we add from HIGHEST priority to LOWEST priority.
// 1. HIGHEST PRIORITY: Custom post type rules (most specific multi-segment patterns)
// Must be added FIRST so they're checked before the catch-all page rule.
// Fixes 404 for WooCommerce products and other CPTs with language prefix
// (e.g., /fr/product/my-product/ or /fr/boutique/produit/my-product/).
$custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'objects');
foreach ($custom_post_types as $cpt) {
$slug = isset($cpt->rewrite['slug']) ? $cpt->rewrite['slug'] : $cpt->name;
// WooCommerce sets rewrite slug to '/product' (with leading slash) — strip it
$slug = trim($slug, '/');
if (empty($slug)) {
continue;
}
// Single CPT post: /fr/product/my-product/
// Use post_type + name (rather than the CPT's query var) so the rule
// resolves even when the post type was registered with query_var disabled
// or a non-default query var — this is the universal form WordPress core
// falls back to. Fixes CPT singles 404ing under the language prefix.
add_rewrite_rule(
'^(' . $lang_pattern . ')/' . preg_quote($slug, '/') . '/([^/]+)/?$',
'index.php?lang=$matches[1]&post_type=' . $cpt->name . '&name=$matches[2]',
'top'
);
// Hierarchical or nested-permalink CPTs: /fr/slug/parent/child/ (and paged).
// The query var of a hierarchical post type resolves a nested path, so allow
// slashes in the captured slug. Added after the single-segment rule so the
// more specific rule keeps priority for flat CPTs.
if (is_post_type_hierarchical($cpt->name) || !empty($cpt->rewrite['hierarchical'])) {
add_rewrite_rule(
'^(' . $lang_pattern . ')/' . preg_quote($slug, '/') . '/(.+?)/page/([0-9]+)/?$',
'index.php?lang=$matches[1]&' . $cpt->name . '=$matches[2]&paged=$matches[3]',
'top'
);
add_rewrite_rule(
'^(' . $lang_pattern . ')/' . preg_quote($slug, '/') . '/(.+?)/?$',
'index.php?lang=$matches[1]&' . $cpt->name . '=$matches[2]',
'top'
);
}
// Archive: /fr/product/ (or /fr/shop/)
if ($cpt->has_archive) {
$archive_slug = $cpt->has_archive === true ? $slug : $cpt->has_archive;
add_rewrite_rule(
'^(' . $lang_pattern . ')/' . preg_quote($archive_slug, '/') . '/?$',
'index.php?lang=$matches[1]&post_type=' . $cpt->name,
'top'
);
add_rewrite_rule(
'^(' . $lang_pattern . ')/' . preg_quote($archive_slug, '/') . '/page/([0-9]+)/?$',
'index.php?lang=$matches[1]&post_type=' . $cpt->name . '&paged=$matches[2]',
'top'
);
}
}
// 2. Tag: /fr/tag/slug/
// Use get_taxonomy() to get the actual rewrite slug — this correctly handles:
// a) Custom tag base set in Settings > Permalinks ($wp_rewrite->tag_base)
// b) WordPress core translation of 'tag' base (e.g. $wp_rewrite->tag_base is
// empty but _x('tag','tag base') returns a localized slug). When category_base
// option is not set, WP uses _x() translation for the base, which $wp_rewrite
// does NOT expose — only the taxonomy rewrite['slug'] has the correct value.
$tag_tax = get_taxonomy('post_tag');
$tag_base = ($tag_tax && !empty($tag_tax->rewrite['slug']))
? trim($tag_tax->rewrite['slug'], '/')
: 'tag';
// Pagination first: /fr/tag/slug/page/2/
add_rewrite_rule(
'^(' . $lang_pattern . ')/' . preg_quote($tag_base, '/') . '/(.+?)/page/([0-9]+)/?$',
'index.php?lang=$matches[1]&tag=$matches[2]&paged=$matches[3]',
'top'
);
add_rewrite_rule(
'^(' . $lang_pattern . ')/' . preg_quote($tag_base, '/') . '/(.+?)/?$',
'index.php?lang=$matches[1]&tag=$matches[2]',
'top'
);
// 3. Category: /fr/category/slug/ (or /fr/categorie/slug/ on French sites, etc.)
// Same reasoning: read from get_taxonomy('category')->rewrite['slug'] not $wp_rewrite->category_base,
// because when category_base option is empty, WP uses _x('category','category base') as the
// slug — e.g. French core translates it to 'categorie'. $wp_rewrite->category_base stays ''
// so reading it would fall back to the English 'category' and generate wrong rules.
$cat_tax = get_taxonomy('category');
$cat_base = ($cat_tax && !empty($cat_tax->rewrite['slug']))
? trim($cat_tax->rewrite['slug'], '/')
: 'category';
// Pagination first: /fr/categorie/slug/page/2/
add_rewrite_rule(
'^(' . $lang_pattern . ')/' . preg_quote($cat_base, '/') . '/(.+?)/page/([0-9]+)/?$',
'index.php?lang=$matches[1]&category_name=$matches[2]&paged=$matches[3]',
'top'
);
add_rewrite_rule(
'^(' . $lang_pattern . ')/' . preg_quote($cat_base, '/') . '/(.+?)/?$',
'index.php?lang=$matches[1]&category_name=$matches[2]',
'top'
);
// 3b. Custom taxonomy terms — WooCommerce product categories, product tags, etc.
// Handles: /fr/product-category/slug/ and hierarchical /fr/product-category/parent/child/
// Must be added before the catch-all page rule but after specific CPT rules.
$public_taxonomies = get_taxonomies(['public' => true, '_builtin' => false], 'objects');
foreach ($public_taxonomies as $taxonomy) {
if (empty($taxonomy->rewrite) || empty($taxonomy->rewrite['slug']) || empty($taxonomy->query_var)) {
continue;
}
$tax_slug = trim($taxonomy->rewrite['slug'], '/');
if (empty($tax_slug)) {
continue;
}
// Pagination first (higher priority): /fr/product-category/slug/page/2/
add_rewrite_rule(
'^(' . $lang_pattern . ')/' . preg_quote($tax_slug, '/') . '/(.+?)/page/([0-9]+)/?$',
'index.php?lang=$matches[1]&' . $taxonomy->query_var . '=$matches[2]&paged=$matches[3]',
'top'
);
// Term archive: /fr/product-category/slug/ (supports parent/child hierarchy via .+?)
add_rewrite_rule(
'^(' . $lang_pattern . ')/' . preg_quote($tax_slug, '/') . '/(.+?)/?$',
'index.php?lang=$matches[1]&' . $taxonomy->query_var . '=$matches[2]',
'top'
);
}
// 4. Feed: /fr/feed/
add_rewrite_rule(
'^(' . $lang_pattern . ')/feed/(feed|rdf|rss|rss2|atom)/?$',
'index.php?lang=$matches[1]&feed=$matches[2]',
'top'
);
add_rewrite_rule(
'^(' . $lang_pattern . ')/feed/?$',
'index.php?lang=$matches[1]&feed=feed',
'top'
);
// 5. Pagination: /fr/page/2/
add_rewrite_rule(
'^(' . $lang_pattern . ')/page/([0-9]+)/?$',
'index.php?lang=$matches[1]&paged=$matches[2]',
'top'
);
// Search: /fr/?s=query (handled by query var, no special rewrite needed)
// 5b. Paginated pages with /page/ base: /fr/blog/page/2/
// Standard WordPress pagination for the posts page and static pages.
// e.g. /fr/blog/page/2/ → lang=fr, pagename=blog, paged=2
// Also handles translated pagination bases (e.g. 'pagina' in some locales).
global $wp_rewrite;
$pagination_base = (isset($wp_rewrite) && !empty($wp_rewrite->pagination_base))
? trim($wp_rewrite->pagination_base, '/')
: 'page';
add_rewrite_rule(
'^(' . $lang_pattern . ')/([^/]+)/' . preg_quote($pagination_base, '/') . '/([0-9]+)/?$',
'index.php?lang=$matches[1]&pagename=$matches[2]&paged=$matches[3]',
'top'
);
// 5c. Paginated pages WITHOUT /page/ base: /fr/blog/2/
// Some themes generate blog pagination as /{posts-page-slug}/{num}/ without
// a 'page' segment in between. The second segment is purely numeric to avoid
// conflicting with CPT/taxonomy slugs (which are handled earlier with higher priority).
add_rewrite_rule(
'^(' . $lang_pattern . ')/([^/]+)/([0-9]+)/?$',
'index.php?lang=$matches[1]&pagename=$matches[2]&paged=$matches[3]',
'top'
);
// 5d. WooCommerce / plugin endpoint URLs: /fr/page-slug/endpoint/ (and /value/)
// e.g. /fr/mon-espace-lavie/commandes/, /fr/mon-espace-lavie/editer-adresse/facturation/
// WooCommerce registers endpoints via add_rewrite_endpoint() at init:20, so
// $wp_rewrite->endpoints is fully populated by the time we reach init:25 here.
// Adding explicit rules per endpoint is more reliable than parse_request fixups because
// WordPress resolves pagename + endpoint query var in one step, matching how it handles
// these URLs without the lang prefix.
if (!empty($wp_rewrite->endpoints) && is_array($wp_rewrite->endpoints)) {
foreach ($wp_rewrite->endpoints as $ep) {
if (!is_array($ep) || count($ep) < 2) {
continue;
}
$ep_places = (int) $ep[0];
$ep_slug = (string) $ep[1];
// $ep[2] is always a string by the time WP stores it (never boolean true)
$ep_qvar = (isset($ep[2]) && $ep[2] !== '') ? (string) $ep[2] : $ep_slug;
// Only handle page-based endpoints (EP_PAGES = 4)
if (!($ep_places & EP_PAGES) || $ep_slug === '') {
continue;
}
$ep_slug_q = preg_quote($ep_slug, '/');
// /fr/page-slug/endpoint/value/ — endpoint with an optional value
add_rewrite_rule(
'^(' . $lang_pattern . ')/([^/]+)/' . $ep_slug_q . '/([^/]*)/?$',
'index.php?lang=$matches[1]&pagename=$matches[2]&' . $ep_qvar . '=$matches[3]',
'top'
);
// /fr/page-slug/endpoint/ — endpoint without a value
add_rewrite_rule(
'^(' . $lang_pattern . ')/([^/]+)/' . $ep_slug_q . '/?$',
'index.php?lang=$matches[1]&pagename=$matches[2]&' . $ep_qvar . '=',
'top'
);
}
}
// 6. Posts with permalink front base: /fr/blog/post-slug/
// Mirrors common permalink structures like /blog/%postname%/ so prefixed
// default-language post URLs resolve instead of falling through to pages.
if (isset($wp_rewrite) && !empty($wp_rewrite->permalink_structure)) {
$permalink_structure = trim($wp_rewrite->permalink_structure, '/');
if (strpos($permalink_structure, '%postname%') !== false) {
$front_base = trim(str_replace('%postname%', '', $permalink_structure), '/');
if ($front_base !== '' && strpos($front_base, '%') === false) {
add_rewrite_rule(
'^(' . $lang_pattern . ')/' . preg_quote($front_base, '/') . '/([^/]+)/?$',
'index.php?lang=$matches[1]&name=$matches[2]',
'top'
);
}
}
}
// 7. Posts with language prefix: /fr/post-slug/ (single-segment slugs)
add_rewrite_rule(
'^(' . $lang_pattern . ')/([^/]+)/?$',
'index.php?lang=$matches[1]&name=$matches[2]',
'top'
);
// 8. Language root: /fr/
add_rewrite_rule(
'^(' . $lang_pattern . ')/?$',
'index.php?lang=$matches[1]',
'top'
);
// 9. LOWEST PRIORITY: Catch-all for pages/hierarchical content: /fr/parent/child/
// Must be LAST added so it's checked after all specific patterns above.
add_rewrite_rule(
'^(' . $lang_pattern . ')/(.+?)/?$',
'index.php?lang=$matches[1]&pagename=$matches[2]',
'top'
);
}
/**
* Register 'lang' as a query variable
* @param array $vars
* @return array
*/
public function register_query_vars($vars) {
$vars[] = 'lang';
return $vars;
}
/**
* Pre-detects the frontend language on early init.
* This ensures REST API requests (which bypass parse_request hook)
* and other AJAX requests have the correct language set early.
*/
public function init_language_detection() {
if (!$this->is_active()) {
return;
}
if ($this->current_language) {
return;
}
$uri = isset($_SERVER['REQUEST_URI'])
? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI']))
: '';
$q = strpos($uri, '?');
if ($q !== false) {
$uri = substr($uri, 0, $q);
}
$lang = $this->get_language_from_path($uri);
if (!$lang) {
$lang = $this->detect_ajax_language();
}
if ($lang) {
$this->current_language = $lang;
} else {
$this->current_language = $this->get_default_language();
}
}
/**
* Detect language from the incoming request and rewrite query variables.
* Must run on parse_request so it has access to and can modify $wp->query_vars.
* @param WP $wp
*/
public function detect_language_from_request($wp) {
if (!$this->is_active()) {
return;
}
if (isset($wp->query_vars['lang'])) {
$lang = sanitize_text_field($wp->query_vars['lang']);
if ($this->language_exists($lang)) {
$this->current_language = $lang;
}
}
// If we matched a 'name' query var (single-segment slug), check if it's
// actually a page rather than a post. WordPress rewrite matched the
// single-segment rule first (name=), but it may be a page.
if (isset($wp->query_vars['name']) && isset($wp->query_vars['lang'])) {
$slug = $wp->query_vars['name'];
// Check if a page with this slug exists
$page = get_page_by_path($slug, OBJECT, 'page');
if ($page && $page->post_type === 'page') {
// Switch from post lookup to page lookup
$wp->query_vars['pagename'] = $slug;
$wp->query_vars['page_id'] = $page->ID;
unset($wp->query_vars['name']);
}
}
// Runtime fallback: resolve a prefixed CPT single that fell through to the
// catch-all page rule (because the CPT's own prefixed rewrite rule wasn't yet
// flushed). Makes /{lang}/{cpt-slug}/{post-slug}/ work even before a flush.
$this->maybe_resolve_cpt_from_catchall($wp);
// Language-root URL (e.g. /en/) with a static front page configured:
// WordPress doesn't know to show the front page because the rewrite rule
// only sets lang=. Manually point to the static front page (or its translation).
// IMPORTANT: only apply when the ONLY query var set is 'lang' — if any
// other content var is set (product_cat, post_type, name, etc.) it means
// the URL matched a content rule, not the language-root rule, and we must
// not override it with the front page.
if (isset($wp->query_vars['lang'])
&& get_option('show_on_front') === 'page'
) {
// Build the set of non-trivial query vars (exclude lang itself and
// WordPress internal vars that are always present or not content).
$ignore_vars = ['lang' => 1, 'error' => 1, 'preview' => 1, 'robots' => 1,
'favicon' => 1, 'sitemap' => 1, 'sitemap-stylesheet' => 1];
$content_vars = array_filter(
array_diff_key($wp->query_vars, $ignore_vars),
function($v) { return $v !== '' && $v !== false && $v !== null && $v !== 0; }
);
if (empty($content_vars)) {
$front_page_id = (int) get_option('page_on_front');
if ($front_page_id) {
$request_lang = sanitize_text_field($wp->query_vars['lang']);
$default_lang = $this->get_default_language();
// If visiting a non-default language root, find the translated front page
if ($request_lang && $request_lang !== $default_lang) {
$translations = $this->get_post_translations($front_page_id);
if (isset($translations[$request_lang])) {
$front_page_id = $translations[$request_lang];
}
}
$wp->query_vars['page_id'] = $front_page_id;
}
}
}
// Handle sub-path URLs caught by the catch-all pagename rule that are actually
// WooCommerce endpoint URLs (or other plugin endpoint URLs registered via
// add_rewrite_endpoint). The catch-all maps /fr/mon-compte/commandes/ to
// pagename=mon-compte/commandes. No child page exists so WP 404s.
// We detect: base segment = real page, remaining segment = registered endpoint slug,
// then set page_id + endpoint query var so WooCommerce (and other plugins) resolve.
if (isset($wp->query_vars['lang']) && isset($wp->query_vars['pagename'])) {
$pagename = $wp->query_vars['pagename'];
if (strpos($pagename, '/') !== false) {
// Only act if no real hierarchical page exists at the full path
$full_page = get_page_by_path($pagename);
if (!$full_page || $full_page->post_type !== 'page') {
// Split into base page slug + the rest (endpoint path)
$slash_pos = strpos($pagename, '/');
$base_slug = substr($pagename, 0, $slash_pos);
$endpoint_path = substr($pagename, $slash_pos + 1);
$base_page = get_page_by_path($base_slug);
if ($base_page && $base_page->post_type === 'page') {
$matched_qv = null;
$matched_value = '';
// 1. Check WooCommerce endpoint slugs (handles translated slugs too)
if (function_exists('WC') && WC()->query) {
foreach (WC()->query->get_query_vars() as $query_var => $slug) {
if (!is_string($slug) || $slug === '') {
continue;
}
if ($endpoint_path === $slug) {
$matched_qv = $query_var;
$matched_value = '';
break;
}
// Endpoint with sub-value: editer-adresse/facturation
if (strpos($endpoint_path, $slug . '/') === 0) {
$matched_qv = $query_var;
$matched_value = substr($endpoint_path, strlen($slug) + 1);
break;
}
}
}
// 2. Fallback: check any endpoint registered via add_rewrite_endpoint()
// (WooCommerce, WP core, other plugins). Stored in $wp_rewrite->endpoints
// as [bitmask, slug, query_var].
if ($matched_qv === null) {
global $wp_rewrite;
if (!empty($wp_rewrite->endpoints) && is_array($wp_rewrite->endpoints)) {
foreach ($wp_rewrite->endpoints as $ep) {
$slug = isset($ep[1]) ? (string) $ep[1] : '';
$qvar = isset($ep[2]) ? (string) $ep[2] : $slug;
if ($slug === '') {
continue;
}
if ($endpoint_path === $slug) {
$matched_qv = $qvar;
$matched_value = '';
break;
}
if (strpos($endpoint_path, $slug . '/') === 0) {
$matched_qv = $qvar;
$matched_value = substr($endpoint_path, strlen($slug) + 1);
break;
}
}
}
}
if ($matched_qv !== null) {
$page_id = (int) $base_page->ID;
// Translate page to current language if not default
$request_lang = sanitize_text_field($wp->query_vars['lang']);
$default_lang = $this->get_default_language();
if ($request_lang && $request_lang !== $default_lang) {
$translations = $this->get_post_translations($page_id);
if (isset($translations[$request_lang])) {
$page_id = (int) $translations[$request_lang];
}
}
$wp->query_vars['page_id'] = $page_id;
$wp->query_vars[$matched_qv] = $matched_value;
unset($wp->query_vars['pagename']);
}
}
}
}
}
// AJAX requests (e.g. WooCommerce cart fragments / mini-cart) are sent to
// an unprefixed URL, so no language is detected from the path above. Derive
// it from the referring page (the page the request was made from), then a
// language cookie, so the mini-cart, "View cart" link, product links and
// prices render in the visitor's current language instead of the default.
if (!$this->current_language) {
$ajax_lang = $this->detect_ajax_language();
if ($ajax_lang) {
$this->current_language = $ajax_lang;
}
}
if (!$this->current_language) {
$this->current_language = $this->get_default_language();
}
}
/**
* Runtime fallback for prefixed custom-post-type singles that fell through to the
* low-priority catch-all rule (^(lang)/(.+?)/?$ → pagename). When a public CPT's
* own prefixed rewrite rule hasn't been flushed yet — common when the post type is
* registered after our rules are built (JetEngine, SureCart, Pods, etc.) — a URL
* like /nl/realisaties/my-project/ otherwise 404s. Here we detect that the matched
* pagename is "{cpt-rewrite-slug}/{post-slug}" for a real CPT post and rewrite the
* query to that single, so it resolves without waiting for a rewrite flush.
*
* @param WP $wp
*/
private function maybe_resolve_cpt_from_catchall($wp) {
if (empty($wp->query_vars['lang']) || empty($wp->query_vars['pagename'])) {
return;
}
// Only act when the request hasn't already resolved to a post / CPT / page.
if (!empty($wp->query_vars['post_type']) || !empty($wp->query_vars['name'])
|| !empty($wp->query_vars['page_id'])) {
return;
}
$pagename = trim((string) $wp->query_vars['pagename'], '/');
if ($pagename === '') {
return;
}
// A real hierarchical page at this exact path wins — leave it alone.
$page_at_path = get_page_by_path($pagename);
if ($page_at_path && $page_at_path->post_type === 'page') {
return;
}
foreach (get_post_types(['public' => true, '_builtin' => false], 'objects') as $cpt) {
$cpt_slug = isset($cpt->rewrite['slug']) ? $cpt->rewrite['slug'] : $cpt->name;
$cpt_slug = trim((string) $cpt_slug, '/');
if ($cpt_slug === '') {
continue;
}
// Archive: /{lang}/{cpt-slug}/
if ($pagename === $cpt_slug) {
if ($cpt->has_archive) {
unset($wp->query_vars['pagename']);
$wp->query_vars['post_type'] = $cpt->name;
}
return;
}
// Single: /{lang}/{cpt-slug}/{post-slug}/ (nested path for hierarchical CPTs).
if (strpos($pagename, $cpt_slug . '/') === 0) {
$rest = trim(substr($pagename, strlen($cpt_slug) + 1), '/');
if ($rest === '') {
return;
}
$hierarchical = is_post_type_hierarchical($cpt->name);
if ($hierarchical) {
$post_slug = $rest;
} else {
$parts = explode('/', $rest);
$post_slug = end($parts);
}
// Only rewrite if a matching post actually exists (avoids hijacking
// unrelated URLs that merely start with a CPT slug).
$found = get_posts([
'post_type' => $cpt->name,
'name' => $post_slug,
'post_status' => ['publish', 'draft', 'pending', 'private', 'future'],
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'suppress_filters' => true,
]);
if (!empty($found)) {
unset($wp->query_vars['pagename']);
$wp->query_vars['post_type'] = $cpt->name;
$wp->query_vars['name'] = $post_slug;
}
return;
}
}
}
/**
* Extract a valid language code from the leading segment of a URL path.
*
* @param string $path
* @return string|null
*/
public function get_language_from_path($path) {
$path = (string) $path;
$home_path = wp_parse_url(home_url(), PHP_URL_PATH) ?: '';
if ($home_path && $home_path !== '/' && strpos($path, $home_path) === 0) {
$path = substr($path, strlen($home_path));
}
$segment = strtok(ltrim($path, '/'), '/');
if ($segment && $this->language_exists($segment)) {
return $segment;
}
return null;
}
/**
* Determine the language for an AJAX request (which carries no URL prefix)
* from the HTTP referer, falling back to the language cookie.
*
* @return string|null
*/
public function detect_ajax_language() {
// Memoize: this can be called from several render-time filters per request.
static $memo = null;
if ($memo !== null) {
return $memo ?: null;
}
$memo = false;
$is_ajax = (function_exists('wp_doing_ajax') && wp_doing_ajax())
|| (defined('DOING_AJAX') && DOING_AJAX)
|| (defined('REST_REQUEST') && REST_REQUEST) // Store API cart/checkout fragments
|| isset($_GET['wc-ajax'])
|| (isset($_REQUEST['action']) && $_REQUEST['action'] === 'jet_engine_ajax')
|| !empty($_REQUEST['jet_engine_action']);
if (!$is_ajax) {
return null;
}
// 1) First check: determine the language from the queried post/page ID
// (common in Elementor AJAX, JetEngine lazy load, and JetSmartFilters requests).
// Since REST API requests send payload as application/json, we check php://input as well.
$json_data = null;
if (!empty($_SERVER['CONTENT_TYPE']) && strpos(strtolower($_SERVER['CONTENT_TYPE']), 'application/json') !== false) {
$raw_body = file_get_contents('php://input');
if (!empty($raw_body)) {
$json_data = json_decode($raw_body, true);
}
}
$req_data = is_array($json_data) ? array_merge($_REQUEST, $json_data) : $_REQUEST;
$target_post_id = 0;
if (!empty($req_data['post_id'])) {
$target_post_id = absint($req_data['post_id']);
} elseif (!empty($req_data['post_ID'])) {
$target_post_id = absint($req_data['post_ID']);
} elseif (!empty($req_data['editor_post_id'])) {
$target_post_id = absint($req_data['editor_post_id']);
} elseif (!empty($req_data['page_settings']['post_id'])) {
$target_post_id = absint($req_data['page_settings']['post_id']);
}
if (!$target_post_id && !empty($req_data['queried_id'])) {
$queried_obj_data = explode('|', sanitize_text_field(wp_unslash($req_data['queried_id'])));
if (!empty($queried_obj_data[0])) {
$target_post_id = absint($queried_obj_data[0]);
}
} elseif (!$target_post_id && !empty($req_data['page_settings']['queried_id'])) {
$queried_obj_data = explode('|', sanitize_text_field(wp_unslash($req_data['page_settings']['queried_id'])));
if (!empty($queried_obj_data[0])) {
$target_post_id = absint($queried_obj_data[0]);
}
}
if ($target_post_id > 0 && get_post_status($target_post_id)) {
$lang = $this->get_post_language($target_post_id);
if ($lang) {
$memo = $lang;
return $lang;
}
}
// 2) Referring page path (e.g. https://site/en/boutique/).
if (!empty($_SERVER['HTTP_REFERER'])) {
$referer_url = esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER']));
$referer_host = wp_parse_url($referer_url, PHP_URL_HOST);
$current_host = wp_parse_url(home_url(), PHP_URL_HOST);
if ($referer_host === $current_host) {
$referer_path = wp_parse_url($referer_url, PHP_URL_PATH) ?: '';
$lang = $this->get_language_from_path($referer_path);
if ($lang) {
$memo = $lang;
return $lang;
} else {
// Referer is on our site but has no language prefix -> must be default language!
$default = $this->get_default_language();
$memo = $default;
return $default;
}
}
}
// 2) Language cookie set on normal page loads.
if (!empty($_COOKIE['aitrfoel_lang'])) {
$cookie_lang = sanitize_text_field(wp_unslash($_COOKIE['aitrfoel_lang']));
if ($this->language_exists($cookie_lang)) {
$memo = $cookie_lang;
return $cookie_lang;
}
}
return null;
}
/**
* Persist the resolved frontend language in a cookie so AJAX requests
* (cart fragments, etc.) can recover it. Runs on normal frontend page loads.
*/
public function maybe_set_language_cookie() {
if (is_admin() || wp_doing_ajax() || headers_sent()) {
return;
}
if (!$this->is_active()) {
return;
}
$lang = $this->get_current_language();
if (!$lang) {
return;
}
if (isset($_COOKIE['aitrfoel_lang']) && $_COOKIE['aitrfoel_lang'] === $lang) {
return; // Already set to the current language.
}
setcookie('aitrfoel_lang', $lang, time() + DAY_IN_SECONDS, COOKIEPATH ?: '/', COOKIE_DOMAIN);
$_COOKIE['aitrfoel_lang'] = $lang; // Reflect immediately within this request.
}
/**
* Get the currently active frontend language
* @return string
*/
public function get_current_language() {
if ($this->current_language) {
return $this->current_language;
}
return $this->get_default_language();
}
/**
* Redirect unprefixed default-language URLs to their prefixed version
* when the aitrfoel_default_lang_prefix option is enabled.
* E.g., /my-page/ → /fr/my-page/ if French is the default language.
*/
/**
* Decide whether the current (or given) request path must be excluded from
* language routing — i.e. never prefixed and never redirected.
*
* Always excludes non-page endpoints (REST, admin-ajax, WooCommerce API,
* webhooks, feeds, login/xmlrpc) plus any custom paths the site owner lists
* under Settings → "Exclude paths from language routing". This prevents the
* router from prepending a language prefix to plugin API/rewrite endpoints
* (e.g. /wcsag-api/...), which would 404 the request.
*
* @param string|null $request_path Optional path; defaults to REQUEST_URI.
* @return bool
*/
public function is_path_excluded_from_routing($request_path = null) {
// REST API requests.
if (defined('REST_REQUEST') && REST_REQUEST) {
return true;
}
if ($request_path === null) {
$request_path = isset($_SERVER['REQUEST_URI'])
? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI']))
: '/';
}
// Strip query string.
$path = (string) $request_path;
$qpos = strpos($path, '?');
if ($qpos !== false) {
$path = substr($path, 0, $qpos);
}
// Strip the site's home path (subdirectory installs) and normalize.
$home_path = wp_parse_url(home_url(), PHP_URL_PATH) ?: '';
if ($home_path && $home_path !== '/' && strpos($path, $home_path) === 0) {
$path = substr($path, strlen($home_path));
}
$path = '/' . ltrim($path, '/');
// Built-in, always-excluded non-page endpoints.
$builtin_prefixes = [
'/wp-json', // REST
'/wp-admin',
'/wp-login.php',
'/xmlrpc.php',
'/wc-api', // WooCommerce legacy API / webhooks
'/feed',
'/sitemap', // sitemaps and sitemap.xml
];
foreach ($builtin_prefixes as $prefix) {
if (strpos($path, $prefix) === 0) {
return true;
}
}
// User-defined excluded paths/tokens (one per line). A line such as
// "wcsag-api" matches any path containing that segment.
$custom = get_option('aitrfoel_routing_excluded_paths', '');
if (!empty($custom)) {
$lines = preg_split('/[\r\n]+/', (string) $custom);
foreach ($lines as $line) {
$token = trim($line, " \t/");
if ($token !== '' && stripos($path, $token) !== false) {
return true;
}
}
}
return false;
}
public function maybe_redirect_default_lang_prefix() {
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
return;
}
if (!get_option('aitrfoel_default_lang_prefix', false)) {
return;
}
if (!$this->is_active()) {
return;
}
// Never prefix/redirect non-page endpoints (REST, WooCommerce API,
// webhooks, feeds) or user-excluded custom paths (e.g. plugin API roots).
if ($this->is_path_excluded_from_routing()) {
return;
}
$default_lang = $this->get_default_language();
// If we already detected a language from URL prefix, no redirect needed
if ($this->current_language && $this->current_language !== $default_lang) {
return;
}
// Check if the URL already has a language prefix
$request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '/';
$home_path = wp_parse_url(home_url(), PHP_URL_PATH) ?: '';
$relative = $request_uri;
if ($home_path && strpos($relative, $home_path) === 0) {
$relative = substr($relative, strlen($home_path));
}
$relative = ltrim($relative, '/');
// If already prefixed with default lang, no redirect
if (strpos($relative, $default_lang . '/') === 0 || $relative === $default_lang) {
return;
}
// Check if prefixed with any other language
$languages = $this->get_stored_languages();
foreach ($languages as $lang) {
if ($lang['code'] !== $default_lang && (strpos($relative, $lang['code'] . '/') === 0 || $relative === $lang['code'])) {
return; // Already has a different language prefix
}
}
// This is an unprefixed URL that should be the default language — redirect
$redirect_url = home_url('/' . $default_lang . '/' . $relative);
wp_safe_redirect($redirect_url, 301);
exit;
}
/**
* Prevent WordPress canonical redirect from stripping the language prefix.
* Without this, /en/ (front page) gets redirected to / by WordPress core,
* causing a redirect loop with maybe_redirect_default_lang_prefix().
*
* @param string $redirect_url The canonical redirect URL.
* @param string $requested_url The original requested URL.
* @return string|false
*/
public function filter_redirect_canonical($redirect_url, $requested_url) {
if (!$this->is_active() || !$this->current_language) {
return $redirect_url;
}
// If the current URL has a language prefix, don't let WP strip it
$request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '/';
$home_path = wp_parse_url(home_url(), PHP_URL_PATH) ?: '';
$relative = $request_uri;
if ($home_path && strpos($relative, $home_path) === 0) {
$relative = substr($relative, strlen($home_path));
}
$relative = ltrim($relative, '/');
$languages = $this->get_stored_languages();
foreach ($languages as $lang) {
if (strpos($relative, $lang['code'] . '/') === 0 || $relative === $lang['code']) {
// URL has a valid language prefix — block canonical redirect
return false;
}
}
return $redirect_url;
}
/**
* Filter WordPress locale based on the current frontend language.
* This makes WooCommerce, other plugins, and WordPress core
* output their UI strings in the correct language.
*
* @param string $locale Current WordPress locale
* @return string Modified locale
*/
public function filter_locale($locale) {
// current_language is only set on parse_request, but get_locale() is called
// earlier (e.g. when WooCommerce/theme load their translation files on init).
// Detect the language straight from the URL here so the correct .mo files are
// loaded from the start — otherwise WooCommerce UI strings (cart, checkout,
// "View cart", "Subtotal", etc.) stay in the default language.
$lang = $this->current_language ?: $this->detect_language_early();
if (!$lang) {
return $locale;
}
$default_lang = $this->get_default_language();
if ($lang === $default_lang) {
return $locale;
}
$wp_locale = $this->get_wp_locale($lang);
return $wp_locale ?: $locale;
}
/**
* On `init`, force WordPress (and therefore WooCommerce / the theme) to the
* detected frontend language via switch_to_locale(), which reloads any
* translations that were already loaded in the default language. This fixes
* WooCommerce UI strings (cart, checkout, mini-cart labels) staying in the
* source language on translated pages.
*/
public function maybe_switch_locale_early() {
if (is_admin() && !(function_exists('wp_doing_ajax') && wp_doing_ajax()) && !isset($_GET['wc-ajax'])) {
return;
}
$lang = $this->detect_language_early();
if (!$lang || $lang === $this->get_default_language()) {
return;
}
$wp_locale = $this->get_wp_locale($lang);
if ($wp_locale && function_exists('switch_to_locale')) {
// Always switch (even if the locale filter already reports this locale)
// so any translations pre-loaded in the default language are reloaded.
switch_to_locale($wp_locale);
}
}
/**
* Resolve the frontend language from the request URL (and referer/cookie for
* AJAX) before parse_request runs. Used by the locale filter so translation
* files load in the correct language from the earliest hooks. Memoized.
*
* @return string|null
*/
private function detect_language_early() {
static $resolved = null;
if ($resolved !== null) {
return $resolved ?: null;
}
$resolved = false;
if (!$this->is_active()) {
return null;
}
// Never override the wp-admin UI locale (but do allow frontend AJAX).
$is_frontend_ajax = (function_exists('wp_doing_ajax') && wp_doing_ajax())
|| (defined('REST_REQUEST') && REST_REQUEST)
|| isset($_GET['wc-ajax']);
if (is_admin() && !$is_frontend_ajax) {
return null;
}
$uri = isset($_SERVER['REQUEST_URI'])
? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI']))
: '';
$q = strpos($uri, '?');
if ($q !== false) {
$uri = substr($uri, 0, $q);
}
$lang = $this->get_language_from_path($uri);
if (!$lang) {
$lang = $this->detect_ajax_language();
}
if ($lang) {
$resolved = $lang;
return $lang;
}
return null;
}
/**
* Force the HTML attribute to match the current frontend language.
* Runs at priority 999 so it overrides any theme or SEO plugin (e.g. Rank Math)
* that may have set a different lang code based on the site's default locale.
*
* @param string $attributes Existing language_attributes string.
* @return string Modified attributes with correct lang value.
*/
public function filter_language_attributes($attributes) {
if (!$this->current_language) {
return $attributes;
}
$wp_locale = $this->get_wp_locale($this->current_language);
// Convert locale (en_US) to BCP-47 (en-US), fall back to bare code (en)
$lang = $wp_locale ? str_replace('_', '-', $wp_locale) : $this->current_language;
// Replace existing lang="..." if present, otherwise append it
if (strpos($attributes, 'lang=') !== false) {
$attributes = preg_replace('/lang="[^"]*"/', 'lang="' . esc_attr($lang) . '"', $attributes);
} else {
$attributes .= ' lang="' . esc_attr($lang) . '"';
}
return $attributes;
}
/**
* Map a short language code to WordPress locale code.
* E.g. 'cs' → 'cs_CZ', 'de' → 'de_DE', 'ru' → 'ru_RU'
*
* @param string $lang_code Short language code
* @return string|null WordPress locale or null if unknown
*/
private function get_wp_locale($lang_code) {
$map = [
'af' => 'af', 'ar' => 'ar', 'az' => 'az',
'be' => 'bel', 'bg' => 'bg_BG', 'bn' => 'bn_BD',
'bs' => 'bs_BA', 'ca' => 'ca', 'cs' => 'cs_CZ',
'cy' => 'cy', 'da' => 'da_DK', 'de' => 'de_DE',
'el' => 'el', 'en' => 'en_US', 'es' => 'es_ES',
'et' => 'et', 'eu' => 'eu', 'fa' => 'fa_IR',
'fi' => 'fi', 'fr' => 'fr_FR', 'ga' => 'ga',
'gl' => 'gl_ES', 'he' => 'he_IL', 'hi' => 'hi_IN',
'hr' => 'hr', 'hu' => 'hu_HU', 'hy' => 'hy',
'id' => 'id_ID', 'is' => 'is_IS', 'it' => 'it_IT',
'ja' => 'ja', 'ka' => 'ka_GE', 'kk' => 'kk',
'km' => 'km', 'ko' => 'ko_KR', 'lo' => 'lo',
'lt' => 'lt_LT', 'lv' => 'lv', 'mk' => 'mk_MK',
'mn' => 'mn', 'ms' => 'ms_MY', 'my' => 'my_MM',
'nb' => 'nb_NO', 'ne' => 'ne_NP', 'nl' => 'nl_NL',
'nn' => 'nn_NO', 'pl' => 'pl_PL', 'pt' => 'pt_PT',
'ro' => 'ro_RO', 'ru' => 'ru_RU', 'si' => 'si_LK',
'sk' => 'sk_SK', 'sl' => 'sl_SI', 'sq' => 'sq',
'sr' => 'sr_RS', 'sv' => 'sv_SE', 'sw' => 'sw',
'ta' => 'ta_IN', 'te' => 'te', 'th' => 'th',
'tl' => 'tl', 'tr' => 'tr_TR', 'uk' => 'uk',
'ur' => 'ur', 'uz' => 'uz_UZ', 'vi' => 'vi',
'zh' => 'zh_CN', 'an' => 'an', 'as' => 'as',
'lb' => 'lb_LU',
];
return $map[$lang_code] ?? null;
}
/**
* Filter WordPress/WooCommerce page ID options to return translated page ID.
* Ensures is_front_page(), is_shop(), is_cart(), etc. work correctly
* when viewing a translated version of these pages.
*
* @param mixed $pre_value Pre-filter value (false = not filtered yet).
* @param string $option Option name.
* @return mixed Page ID of translated version, or $pre_value to use default.
*/
public function filter_translated_page_option($pre_value, $option) {
if (!$this->is_active()) {
return $pre_value;
}
$current_lang = $this->get_current_language();
$default_lang = $this->get_default_language();
if (!$current_lang || $current_lang === $default_lang) {
return $pre_value;
}
// Prevent infinite loop: temporarily remove this filter while reading the option
static $filtering = [];
if (isset($filtering[$option])) {
return $pre_value;
}
$filtering[$option] = true;
$original_page_id = (int) get_option($option);
unset($filtering[$option]);
if ($original_page_id <= 0) {
return $pre_value;
}
$translations = $this->get_post_translations($original_page_id);
if (isset($translations[$current_lang])) {
$translated_id = (int) $translations[$current_lang];
if ($translated_id > 0 && get_post_status($translated_id) === 'publish') {
return $translated_id;
}
}
return $pre_value;
}
/**
* Filter home_url to include current language prefix.
* Ensures logo links, home links etc. point to the correct language.
*
* @param string $url
* @param string $path
* @param string|null $orig_scheme
* @param int|null $blog_id
* @return string
*/
public function filter_home_url($url, $path, $orig_scheme = null, $blog_id = null) {
if (!$this->is_active()) {
return $url;
}
// Only prefix the root home URL (empty path or just "/").
// Do NOT prefix when WordPress is building post/page permalinks
// (e.g. home_url('/my-page/')) — our permalink filters handle those.
$trimmed_path = trim($path, '/');
if ($trimmed_path !== '') {
return $url;
}
$current_lang = $this->get_current_language();
$default_lang = $this->get_default_language();
// Don't prefix for default language
if (!$current_lang || $current_lang === $default_lang) {
return $url;
}
// Only for pretty permalinks
if (empty(get_option('permalink_structure', ''))) {
return add_query_arg('lang', $current_lang, $url);
}
// Use raw home option to avoid recursion
$raw_home = trailingslashit(get_option('home'));
return $raw_home . $current_lang . '/';
}
/**
* Ensure WooCommerce's AJAX endpoint URL (cart fragments, add-to-cart, etc.)
* includes the current language prefix, so the request is detected as the
* current language directly from the URL — fixing the mini-cart rendering in
* the default language.
*
* @param string $url
* @return string
*/
public function filter_wc_ajax_endpoint($url) {
if (!$this->is_active() || !is_string($url) || $url === '') {
return $url;
}
$current_lang = $this->get_current_language();
$default_lang = $this->get_default_language();
if (!$current_lang || $current_lang === $default_lang) {
return $url;
}
// Pretty permalinks only; plain permalinks already carry ?lang= via home_url.
if (empty(get_option('permalink_structure', ''))) {
return $url;
}
// WooCommerce builds this endpoint as a RELATIVE URL (e.g.
// "/?wc-ajax=%%endpoint%%") via home_url('/', 'relative'), but it can also
// be absolute. Handle both by working on the path component.
$parsed = wp_parse_url($url);
if ($parsed === false) {
return $url;
}
$path = isset($parsed['path']) ? $parsed['path'] : '/';
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
// Strip the site's base path (subdirectory installs) before checking/adding the prefix.
$base_path = wp_parse_url(get_option('home'), PHP_URL_PATH) ?: '';
$base_path = $base_path === '/' ? '' : rtrim($base_path, '/');
$rel = $path;
if ($base_path !== '' && strpos($rel, $base_path) === 0) {
$rel = substr($rel, strlen($base_path));
}
$rel = '/' . ltrim($rel, '/');
// Already language-prefixed? Leave it.
$first_segment = strtok(ltrim($rel, '/'), '/');
$lang_codes = array_column($this->get_stored_languages(), 'code');
if ($first_segment && in_array($first_segment, $lang_codes, true)) {
return $url;
}
$new_path = $base_path . '/' . $current_lang . $rel;
// Preserve absolute form if the original was absolute.
if (!empty($parsed['host'])) {
$scheme = !empty($parsed['scheme']) ? $parsed['scheme'] . '://' : '//';
$port = !empty($parsed['port']) ? ':' . $parsed['port'] : '';
return $scheme . $parsed['host'] . $port . $new_path . $query;
}
return $new_path . $query;
}
/**
* Filter permalink to add language prefix (for posts)
* @param string $permalink
* @param WP_Post|int $post
* @return string
*/
public function filter_permalink($permalink, $post) {
if (!$this->is_active()) {
return $permalink;
}
$post_id = is_object($post) ? $post->ID : $post;
$lang = $this->get_post_language($post_id);
$default_lang = $this->get_default_language();
if (!$lang) {
return $permalink;
}
if ($lang === $default_lang && !get_option('aitrfoel_default_lang_prefix', false)) {
return $permalink;
}
// Plain permalinks: use ?lang= query parameter instead of URL prefix
if (empty(get_option('permalink_structure', ''))) {
return add_query_arg('lang', $lang, $permalink);
}
// Use raw home URL to avoid double-prefix from filter_home_url
$home_url = trailingslashit(get_option('home'));
if (strpos($permalink, $home_url) === 0) {
// Don't double-add language prefix
$relative = substr($permalink, strlen($home_url));
$lang_codes = array_column($this->get_stored_languages(), 'code');
foreach ($lang_codes as $code) {
if (strpos($relative, $code . '/') === 0) {
return $permalink; // Already prefixed
}
}
$permalink = $home_url . $lang . '/' . ltrim($relative, '/');
}
return $permalink;
}
/**
* Filter page permalink to add language prefix
* @param string $permalink
* @param int $page_id
* @return string
*/
public function filter_page_permalink($permalink, $page_id) {
if (!$this->is_active()) {
return $permalink;
}
$lang = $this->get_post_language($page_id);
$default_lang = $this->get_default_language();
if (!$lang) {
return $permalink;
}
// Plain permalinks: use ?lang= query parameter instead of URL prefix
if (empty(get_option('permalink_structure', ''))) {
return add_query_arg('lang', $lang, $permalink);
}
// If this page is a static front page or a translation of the static front page
$front_page_id = (int) get_option('page_on_front');
if ($front_page_id && get_option('show_on_front') === 'page') {
$translations = $this->get_post_translations($front_page_id);
$all_front_ids = array_map('intval', array_values($translations));
$all_front_ids[] = $front_page_id;
if (in_array((int) $page_id, $all_front_ids, true)) {
$raw_home = trailingslashit(get_option('home'));
if ($lang === $default_lang) {
return get_option('aitrfoel_default_lang_prefix', false) ? $raw_home . $default_lang . '/' : $raw_home;
}
return $raw_home . $lang . '/';
}
}
if ($lang === $default_lang && !get_option('aitrfoel_default_lang_prefix', false)) {
return $permalink;
}
// Use raw home URL to avoid double-prefix from filter_home_url
$home_url = trailingslashit(get_option('home'));
if (strpos($permalink, $home_url) === 0) {
$relative = substr($permalink, strlen($home_url));
$lang_codes = array_column($this->get_stored_languages(), 'code');
foreach ($lang_codes as $code) {
if (strpos($relative, $code . '/') === 0) {
return $permalink;
}
}
$permalink = $home_url . $lang . '/' . ltrim($relative, '/');
}
return $permalink;
}
/**
* Convert a translated Shop page resolved as a singular page into a clean product archive.
*
* When WooCommerce resolves /en/shop/ it uses the product post-type archive (no page_id
* constraint). But a translated shop page (e.g. /ru/магазин/) resolves as a normal
* singular page (is_page=true, page_id=9673). WooCommerce never strips that singular
* constraint, so the product query ends up as:
* WHERE wp_posts.ID = '9673' AND post_type = 'product'
* which always returns 0 rows.
*
* Fix: at priority 5 (before WooCommerce's product_query at priority 10), detect this
* case and convert the query into a proper product archive — clearing is_page/is_singular
* and the page_id, and setting is_post_type_archive so WooCommerce builds the correct query.
*
* @param WP_Query $query
*/
public function maybe_convert_shop_page_query($query) {
$active = $this->is_active();
$is_main = $query->is_main_query();
$is_page = $query->is_page();
$qid = (int) $query->get('page_id') ?: (int) $query->queried_object_id;
$wc_ok = function_exists('wc_get_page_id') && class_exists('WooCommerce');
$shop_id = $wc_ok ? (int) wc_get_page_id('shop') : 0;
if (!$active || is_admin() || !$is_main) {
return;
}
if (!$wc_ok) {
return;
}
// Only act when the query resolved as a singular page.
if (!$is_page) {
return;
}
$queried_id = $qid;
if (!$queried_id) {
return;
}
// Check whether this page is the (translated) WooCommerce shop page.
// wc_get_page_id('shop') already returns the translated ID because our
// pre_option_woocommerce_shop_page_id filter is in place.
$shop_page_id = $shop_id;
if (!$shop_page_id || $queried_id !== $shop_page_id) {
return;
}
// Convert into a product post-type archive: clear all singular flags,
// set the archive flags, remove the page_id constraint.
// IMPORTANT: use '' (empty string) not 0 — WordPress uses '' !== $q['page_id']
// with strict inequality, so integer 0 would still add AND ID = '0' to the SQL.
$query->is_page = false;
$query->is_singular = false;
$query->is_archive = true;
$query->is_post_type_archive = true;
$query->set('post_type', 'product');
$query->set('page_id', '');
$query->set('p', '');
$query->set('pagename', '');
// Preserve WooCommerce's expected per-page count.
$per_page = (int) get_option('posts_per_page', 12);
if (function_exists('wc_get_default_products_per_row')) {
$per_page = max($per_page, (int) get_option('woocommerce_catalog_columns', 4) * (int) get_option('woocommerce_catalog_rows', 4));
}
$query->set('posts_per_page', $per_page);
}
/**
* Filter frontend queries to show only current language posts
* @param WP_Query $query
*/
public function filter_frontend_queries($query) {
if (!$this->is_active() || is_admin() || !$query->is_main_query()) {
return;
}
$current_lang = $this->get_current_language();
if (!$current_lang) {
return;
}
// Filter archives, search, blog index, taxonomy archives
if ($query->is_archive() || $query->is_home() || $query->is_search() || $query->is_tax()) {
$default_lang = $this->get_default_language();
$terms = [$current_lang];
// WooCommerce product fallback: include default language products
// so untranslated products still appear on shop/category pages
$is_product_fallback = false;
if ($current_lang !== $default_lang && class_exists('WooCommerce') && get_option('aitrfoel_woo_product_fallback', true)) {
$queried_post_type = $query->get('post_type');
$is_product = ($queried_post_type === 'product')
|| (is_array($queried_post_type) && in_array('product', $queried_post_type, true));
if ($is_product) {
$terms[] = $default_lang;
$is_product_fallback = true;
}
}
$tax_query = $query->get('tax_query') ?: [];
$tax_query[] = [
'taxonomy' => self::TAXONOMY,
'field' => 'slug',
'terms' => $terms,
];
$query->set('tax_query', $tax_query);
// Deduplicate: remove default-lang products that have a current-lang translation
if ($is_product_fallback) {
add_filter('the_posts', [$this, 'deduplicate_fallback_products'], 10, 2);
}
}
// For single posts/pages: if language prefix is in URL but post is in a different language,
// WordPress will handle the 404 naturally. We just ensure the current language is set.
}
/**
* Remove default-language products from results when a current-language translation exists.
* Runs once as a the_posts filter after WooCommerce product fallback query.
*
* @param array $posts Array of WP_Post objects.
* @param WP_Query $query The query object.
* @return array
*/
public function deduplicate_fallback_products($posts, $query) {
remove_filter('the_posts', [$this, 'deduplicate_fallback_products'], 10);
if (empty($posts)) {
return $posts;
}
$current_lang = $this->get_current_language();
$default_lang = $this->get_default_language();
// Collect IDs of default-lang source posts that have a translated version in this result set
$translated_source_ids = [];
foreach ($posts as $post) {
$lang = $this->get_post_language($post->ID);
if ($lang === $current_lang) {
// This is a translated product — find its source (default lang) post
$translations = $this->get_post_translations($post->ID);
if (isset($translations[$default_lang])) {
$translated_source_ids[] = (int) $translations[$default_lang];
}
}
}
// Remove default-lang products whose translation is already in the results
if (!empty($translated_source_ids)) {
$posts = array_values(array_filter($posts, function($post) use ($translated_source_ids, $default_lang) {
$lang = $this->get_post_language($post->ID);
return !($lang === $default_lang && in_array((int) $post->ID, $translated_source_ids, true));
}));
}
return $posts;
}
/**
* Filter a single term to show its translated name on the frontend.
* Reads from term meta `_aitrfoel_name_{lang}` set during translation.
*
* @param WP_Term $term Term object.
* @param string $taxonomy Taxonomy slug.
* @return WP_Term
*/
public function filter_term_name($term, $taxonomy) {
if (!is_object($term) || !isset($term->term_id)) {
return $term;
}
// Skip our own language taxonomy
if ($taxonomy === self::TAXONOMY) {
return $term;
}
$current_lang = $this->get_current_language();
// Translate term names for any resolved language with a stored translation —
// including the default, since a switched default still needs its terms shown
// in the new language. When no _aitrfoel_name_{lang} meta exists (e.g. the
// original language), the term is left unchanged, so this is safe everywhere.
if (!$current_lang) {
return $term;
}
$translated_name = get_term_meta($term->term_id, '_aitrfoel_name_' . $current_lang, true);
if (!empty($translated_name)) {
$term->name = $translated_name;
}
return $term;
}
/**
* Filter an array of terms to show translated names on the frontend.
*
* @param array $terms Array of found terms.
* @param array|null $taxonomies Array of taxonomy slugs (may be null).
* @param array $query_vars Query variables.
* @param WP_Term_Query $term_query Term query object.
* @return array
*/
public function filter_terms_names($terms, $taxonomies, $query_vars, $term_query = null) {
if (empty($terms)) {
return $terms;
}
$current_lang = $this->get_current_language();
// See filter_term_name: run for any resolved language; terms without a stored
// translation for that language are left unchanged.
if (!$current_lang) {
return $terms;
}
foreach ($terms as &$term) {
if (!is_object($term) || !isset($term->term_id)) {
continue;
}
// Skip our language taxonomy
if (isset($term->taxonomy) && $term->taxonomy === self::TAXONOMY) {
continue;
}
$translated_name = get_term_meta($term->term_id, '_aitrfoel_name_' . $current_lang, true);
if (!empty($translated_name)) {
$term->name = $translated_name;
}
}
unset($term);
return $terms;
}
/**
* Translate JetSmartFilters labels at render time.
*
* Hooked on `jet-smart-filters/filter-instance/args` — the single point where
* every option-based filter (radio, checkboxes, select, color-image, …) exposes
* its fully-built args, including the prepended "Show all" option. This means one
* hook covers:
* - taxonomy option labels (resolved from `_aitrfoel_name_{lang}` term meta, so
* it works even when JetSmartFilters' indexer caches the default-language
* names and bypasses the get_terms() filter),
* - the admin-entered "Show all" label and other non-term option labels
* (resolved through the custom-string map), and
* - helper strings like the dropdown placeholder / more / less text.
*
* Runs on the front end and in JetSmartFilters AJAX re-renders. Language is
* resolved locally (not via $this->st_lang, which is only set on the `wp` action
* that admin-ajax does not fire).
*
* @param array $args The filter instance args (passed by reference value).
* @param object $instance The Jet_Smart_Filters_Filter_Instance (unused).
* @return array
*/
/**
* Resolve a single admin string through the custom-string map for a given
* language. Tries the raw value and an entity-decoded copy (some plugins
* htmlspecialchars()-encode labels before render), so the lookup matches the
* stored source. Returns the input unchanged when there's no override.
*
* @param string $text
* @param string $lang Target language code.
* @param array|null $map Optional pre-fetched map (avoids repeated lookups).
* @return string
*/
private function map_translate($text, $lang, $map = null) {
if (!is_string($text) || $text === '' || !$lang) {
return $text;
}
if ($map === null) {
$map = $this->get_string_translation_map();
}
foreach ([trim($text), trim(html_entity_decode($text, ENT_QUOTES))] as $key) {
if ($key !== '' && isset($map[$key][$lang]) && $map[$key][$lang] !== '') {
return $map[$key][$lang];
}
}
return $text;
}
/**
* Translate JetFormBuilder field blocks before they render.
*
* Hooked on WordPress core's `render_block_data`, which fires for every block
* on the page. We bail immediately for anything outside the `jet-forms/*`
* namespace, then translate the field's user-facing string attributes
* (label, description, placeholder, option labels, add-more / page-nav button
* text) through the custom-string map. Field attribute keys are stable and
* confirmed from JetFormBuilder's own form patterns.
*
* @param array $parsed_block A single parsed block array.
* @return array
*/
public function translate_jfb_block_attrs($parsed_block) {
if (empty($parsed_block['blockName']) || strpos($parsed_block['blockName'], 'jet-forms/') !== 0) {
return $parsed_block;
}
if (empty($parsed_block['attrs']) || !is_array($parsed_block['attrs']) || !$this->is_active()) {
return $parsed_block;
}
$lang = $this->current_language_resolved();
$default = $this->get_default_language();
if (!$lang || $lang === $default) {
return $parsed_block;
}
$map = $this->get_string_translation_map();
$a = &$parsed_block['attrs'];
foreach (['label', 'desc', 'placeholder', 'add_more_label', 'prev_label', 'next_label'] as $k) {
if (!empty($a[$k]) && is_string($a[$k])) {
$a[$k] = $this->map_translate($a[$k], $lang, $map);
}
}
if (!empty($a['field_options']) && is_array($a['field_options'])) {
foreach ($a['field_options'] as &$opt) {
if (is_array($opt) && !empty($opt['label']) && is_string($opt['label'])) {
$opt['label'] = $this->map_translate($opt['label'], $lang, $map);
}
}
unset($opt);
}
unset($a);
return $parsed_block;
}
/**
* Translate a WPForms form before render. Hooked on `wpforms_frontend_form_data`.
* Covers field labels/descriptions/placeholders, choice labels, the submit button
* text, and confirmation messages. Strings resolve through the custom-string map.
*
* @param array $form_data
* @return array
*/
public function translate_wpforms_form_data($form_data) {
if (!is_array($form_data) || !$this->is_active()) {
return $form_data;
}
$lang = $this->current_language_resolved();
$default = $this->get_default_language();
if (!$lang || $lang === $default) {
return $form_data;
}
$map = $this->get_string_translation_map();
if (!empty($form_data['fields']) && is_array($form_data['fields'])) {
foreach ($form_data['fields'] as &$field) {
if (!is_array($field)) {
continue;
}
foreach (['label', 'description', 'placeholder'] as $k) {
if (!empty($field[$k]) && is_string($field[$k])) {
$field[$k] = $this->map_translate($field[$k], $lang, $map);
}
}
if (!empty($field['choices']) && is_array($field['choices'])) {
foreach ($field['choices'] as &$choice) {
if (is_array($choice) && !empty($choice['label']) && is_string($choice['label'])) {
$choice['label'] = $this->map_translate($choice['label'], $lang, $map);
}
}
unset($choice);
}
}
unset($field);
}
if (!empty($form_data['settings']) && is_array($form_data['settings'])) {
$s = &$form_data['settings'];
foreach (['submit_text', 'submit_text_processing'] as $k) {
if (!empty($s[$k]) && is_string($s[$k])) {
$s[$k] = $this->map_translate($s[$k], $lang, $map);
}
}
if (!empty($s['confirmations']) && is_array($s['confirmations'])) {
foreach ($s['confirmations'] as &$conf) {
if (is_array($conf) && !empty($conf['message']) && is_string($conf['message'])) {
$conf['message'] = $this->map_translate($conf['message'], $lang, $map);
}
}
unset($conf);
}
unset($s);
}
return $form_data;
}
/**
* Translate a Gravity Forms form before render. Hooked on `gform_pre_render`.
* GF passes an array whose `fields` are GF_Field objects (mutated by reference),
* with choice labels under the `text` key. Also covers the submit button text and
* confirmation messages.
*
* @param array $form
* @return array
*/
public function translate_gform_form($form) {
if (!is_array($form) || !$this->is_active()) {
return $form;
}
$lang = $this->current_language_resolved();
$default = $this->get_default_language();
if (!$lang || $lang === $default) {
return $form;
}
$map = $this->get_string_translation_map();
if (!empty($form['fields']) && is_array($form['fields'])) {
foreach ($form['fields'] as $field) {
if (!is_object($field)) {
continue;
}
foreach (['label', 'description', 'placeholder'] as $k) {
if (!empty($field->$k) && is_string($field->$k)) {
$field->$k = $this->map_translate($field->$k, $lang, $map);
}
}
if (!empty($field->choices) && is_array($field->choices)) {
$choices = $field->choices;
foreach ($choices as &$choice) {
if (is_array($choice) && !empty($choice['text']) && is_string($choice['text'])) {
$choice['text'] = $this->map_translate($choice['text'], $lang, $map);
}
}
unset($choice);
$field->choices = $choices;
}
}
}
if (!empty($form['button']['text']) && is_string($form['button']['text'])) {
$form['button']['text'] = $this->map_translate($form['button']['text'], $lang, $map);
}
if (!empty($form['confirmations']) && is_array($form['confirmations'])) {
foreach ($form['confirmations'] as &$conf) {
if (is_array($conf) && !empty($conf['message']) && is_string($conf['message'])) {
$conf['message'] = $this->map_translate($conf['message'], $lang, $map);
}
}
unset($conf);
}
return $form;
}
/**
* Translate a Contact Form 7 form's rendered HTML. Hooked on `wpcf7_form_elements`.
* CF7 forms are free-form markup, so we translate visible text nodes only (never
* tag names or attributes) via the custom-string map.
*
* @param string $html
* @return string
*/
public function translate_cf7_form_html($html) {
if (!is_string($html) || $html === '' || !$this->is_active()) {
return $html;
}
$lang = $this->current_language_resolved();
$default = $this->get_default_language();
if (!$lang || $lang === $default) {
return $html;
}
$map = $this->get_string_translation_map();
if (empty($map)) {
return $html;
}
return $this->translate_html_text_nodes($html, $lang, $map);
}
/**
* Translate a Contact Form 7 response message (validation / success). Hooked on
* `wpcf7_display_message`.
*
* @param string $message
* @param string $status
* @return string
*/
public function translate_cf7_display_message($message, $status = '') {
if (!is_string($message) || $message === '' || !$this->is_active()) {
return $message;
}
$lang = $this->current_language_resolved();
$default = $this->get_default_language();
if (!$lang || $lang === $default) {
return $message;
}
return $this->map_translate($message, $lang, $this->get_string_translation_map());
}
/**
* Translate the visible text nodes of an HTML fragment through the custom-string
* map, leaving tags and attributes untouched. Used for free-form markup (CF7).
*
* @param string $html
* @param string $lang
* @param array $map
* @return string
*/
private function translate_html_text_nodes($html, $lang, $map) {
// Plain text (no markup): translate directly.
if (strpos($html, '<') === false) {
return $this->map_translate($html, $lang, $map);
}
if (!class_exists('DOMDocument')) {
return $html;
}
$prev = libxml_use_internal_errors(true);
$doc = new DOMDocument();
// Force UTF-8 and wrap in a marker element so we can extract just the fragment.
$wrapped = '
' . $html . '
';
$loaded = $doc->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
if (!$loaded) {
libxml_clear_errors();
libxml_use_internal_errors($prev);
return $html;
}
$xpath = new DOMXPath($doc);
$changed = false;
foreach ($xpath->query('//text()') as $node) {
$text = $node->nodeValue;
if (trim($text) === '') {
continue;
}
$parent = $node->parentNode ? strtolower($node->parentNode->nodeName) : '';
if ($parent === 'script' || $parent === 'style') {
continue;
}
$trimmed = trim($text);
$tr = $this->map_translate($trimmed, $lang, $map);
if ($tr !== $trimmed) {
// Preserve the node's surrounding whitespace.
$node->nodeValue = str_replace($trimmed, $tr, $text);
$changed = true;
}
}
if (!$changed) {
libxml_clear_errors();
libxml_use_internal_errors($prev);
return $html;
}
$wrap = $xpath->query('//div[@data-aitrfoel-cf7]')->item(0);
$out = '';
if ($wrap) {
foreach ($wrap->childNodes as $child) {
$out .= $doc->saveHTML($child);
}
}
libxml_clear_errors();
libxml_use_internal_errors($prev);
return $out !== '' ? $out : $html;
}
/**
* Translate a SureCart product's name / description at the model level. Hooked on
* `surecart/product/get_attribute`, which fires whenever any block or template
* reads a product attribute, so a single hook covers the product page, collection
* grids, checkout line items, etc.
*
* SureCart products are translated like WooCommerce products: into a linked post
* copy whose post_title holds the translated name and post_excerpt the translated
* description (created and reviewed through the dashboard's Products tab). So this
* returns the current-language copy's field rather than a flat string override.
* The base Model guards against re-entrancy; we read only the passed value and the
* product id to stay loop-safe.
*
* @param mixed $value The attribute value.
* @param string $key The attribute name.
* @param object $product The product model.
* @return mixed
*/
public function translate_surecart_product_attribute($value, $key, $product = null) {
if (!is_string($value) || $value === '' || !$this->is_active()) {
return $value;
}
if ($key !== 'name' && $key !== 'description') {
return $value;
}
$lang = $this->current_language_resolved();
$default = $this->get_default_language();
if (!$lang || $lang === $default) {
return $value;
}
$sc_id = is_object($product) ? ($product->id ?? '') : '';
$fields = $this->get_surecart_translated_fields($sc_id, $lang);
if ($fields === null) {
return $value;
}
if ($key === 'name' && $fields['name'] !== '') {
return $fields['name'];
}
if ($key === 'description' && $fields['description'] !== '') {
return $fields['description'];
}
return $value;
}
/**
* Resolve the current-language translated copy of a SureCart product and return
* its name (post_title) and description (post_excerpt). Mapped by the product's
* cloud id (the `sc_id` post meta SureCart's sync stores on every product post)
* and cached per request. Returns null when no translation exists.
*
* @param string $sc_id SureCart product id.
* @param string $lang Target language code.
* @return array|null ['name' => string, 'description' => string] or null.
*/
private function get_surecart_translated_fields($sc_id, $lang) {
if (!is_string($sc_id) || $sc_id === '') {
return null;
}
$ck = $sc_id . '|' . $lang;
if (array_key_exists($ck, $this->sc_translated_fields_cache)) {
return $this->sc_translated_fields_cache[$ck];
}
$result = null;
$ids = get_posts([
'post_type' => 'sc_product',
'post_status' => 'any',
'numberposts' => 1,
'fields' => 'ids',
'meta_key' => 'sc_id',
'meta_value' => $sc_id,
'suppress_filters' => true,
]);
if (!empty($ids)) {
$translations = $this->get_post_translations($ids[0]);
if (!empty($translations[$lang])) {
$tp = get_post($translations[$lang]);
if ($tp) {
$result = [
'name' => (string) $tp->post_title,
'description' => (string) $tp->post_excerpt,
];
}
}
}
$this->sc_translated_fields_cache[$ck] = $result;
return $result;
}
public function translate_jsf_filter_args($args, $instance = null) {
if (!is_array($args) || empty($args['options']) || !is_array($args['options']) || !$this->is_active()) {
return $args;
}
$lang = $this->current_language_resolved();
$default = $this->get_default_language();
if (!$lang || $lang === $default) {
return $args;
}
$map = $this->get_string_translation_map();
$taxonomy = (isset($args['query_type']) && $args['query_type'] === 'tax_query' && !empty($args['query_var']))
? $args['query_var']
: '';
// Resolve an admin string through the custom-string map.
$tr_string = function ($text) use ($map, $lang) {
return $this->map_translate($text, $lang, $map);
};
// Prefer the translated term name from term meta; fall back to the string map.
$tr_term = function ($term_id, $fallback) use ($taxonomy, $lang, $tr_string) {
if ($taxonomy && is_numeric($term_id)) {
$name = get_term_meta((int) $term_id, '_aitrfoel_name_' . $lang, true);
if (!empty($name)) {
return $name;
}
}
return $tr_string($fallback);
};
foreach ($args['options'] as $key => &$opt) {
if ($key === 'all') {
// The "Show all" entry: a string (flat) or {term_id:'all', name} object
// (hierarchical). Always an admin label, never a term.
if (is_object($opt) && isset($opt->name)) {
$opt->name = $tr_string($opt->name);
} elseif (is_string($opt)) {
$opt = $tr_string($opt);
}
continue;
}
if (is_string($opt)) {
$opt = $tr_term($key, $opt);
} elseif (is_array($opt) && isset($opt['label']) && is_string($opt['label'])) {
$val = isset($opt['value']) ? $opt['value'] : $key;
$opt['label'] = $tr_term($val, $opt['label']);
} elseif (is_object($opt) && isset($opt->name)) {
$tid = isset($opt->term_id) ? $opt->term_id : $key;
$opt->name = $tr_term($tid, $opt->name);
}
}
unset($opt);
// Helper strings that aren't always routed through gettext.
foreach ([
'dropdown_placeholder', 'more_text', 'less_text', 'search_placeholder',
'show_all_filter_label', 'show_all_placeholder', 'show_all_label',
'filter_placeholder', 'apply_button_text', 'remove_filters_button_text'
] as $sk) {
if (!empty($args[$sk]) && is_string($args[$sk])) {
$args[$sk] = $tr_string($args[$sk]);
}
}
return $args;
}
// =========================================================================
// SEO: HREFLANG TAGS
// =========================================================================
/**
* Output hreflang link tags in
*/
public function output_hreflang_tags() {
if (!$this->is_active() || is_admin()) {
return;
}
$post_id = get_queried_object_id();
if (!$post_id) {
return;
}
$translations = $this->get_post_translations($post_id);
if (count($translations) < 2) {
return;
}
echo "\n\n";
foreach ($translations as $lang_code => $trans_post_id) {
$url = $this->get_post_permalink($trans_post_id);
printf(
'' . "\n",
esc_attr($lang_code),
esc_url($url)
);
}
// x-default points to default language version
$default_lang = $this->get_default_language();
if (isset($translations[$default_lang])) {
printf(
'' . "\n",
esc_url($this->get_post_permalink($translations[$default_lang]))
);
}
echo "\n";
}
/**
* Synchronizes key attributes (menu_order, post_date, post_date_gmt) from
* the original post to all of its translated versions.
*/
public function sync_translation_attributes($post_id, $post, $update) {
if (!$this->is_active()) {
return;
}
// Skip revisions, drafts, auto-drafts, and trashing/deleting operations
if (wp_is_post_revision($post_id) || !$post || $post->post_status === 'auto-draft' || $post->post_status === 'trash') {
return;
}
// Avoid infinite recursion loops by checking if this is already a translation
$source_page = get_post_meta($post_id, '_aitrfoel_source_page', true);
if (!empty($source_page)) {
return; // This is a translation post, do not sync backwards
}
global $wpdb;
$translations = $wpdb->get_col(
$wpdb->prepare(
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_aitrfoel_source_page' AND meta_value = %s",
(string) $post_id
)
);
if (empty($translations)) {
return;
}
foreach ($translations as $tr_id) {
$tr_post = get_post((int) $tr_id);
if (!$tr_post) {
continue;
}
$updates = [];
if ($tr_post->menu_order !== $post->menu_order) {
$updates['menu_order'] = $post->menu_order;
}
if ($tr_post->post_date !== $post->post_date) {
$updates['post_date'] = $post->post_date;
}
if ($tr_post->post_date_gmt !== $post->post_date_gmt) {
$updates['post_date_gmt'] = $post->post_date_gmt;
}
if (!empty($updates)) {
$wpdb->update(
$wpdb->posts,
$updates,
['ID' => (int) $tr_id]
);
clean_post_cache((int) $tr_id);
}
}
}
/**
/**
* Fixes JetProductGallery swiper container height issues on load.
* Often, Swiper initializes before lazy-loaded images finish loading,
* causing it to calculate a wrong/zero height until user interaction.
*/
public function fix_jet_gallery_swiper_height() {
?>
is_active()) {
return $args;
}
$default_lang = $this->get_default_language();
if (!$default_lang) {
return $args;
}
$tax_query = isset($args['tax_query']) ? $args['tax_query'] : [];
$tax_query[] = [
'taxonomy' => self::TAXONOMY,
'field' => 'slug',
'terms' => $default_lang,
];
$args['tax_query'] = $tax_query;
return $args;
}
// =========================================================================
// ADMIN: POSTS LIST LANGUAGE COLUMN
// =========================================================================
/**
* Add language column to posts/pages list
* @param array $columns
* @return array
*/
public function add_language_column($columns) {
if (!$this->is_active()) {
return $columns;
}
// Insert after title column
$new_columns = [];
foreach ($columns as $key => $label) {
$new_columns[$key] = $label;
if ($key === 'title') {
$new_columns['aitrfoel_language'] = '' . __('Lang', 'ai-translator-for-elementor-and-polylang') . '';
}
}
return $new_columns;
}
/**
* Render language column content
* @param string $column
* @param int $post_id
*/
public function render_language_column($column, $post_id) {
if ($column !== 'aitrfoel_language') {
return;
}
$lang = $this->get_post_language($post_id);
if ($lang) {
$default = $this->get_default_language();
echo '' . esc_html(strtoupper($lang));
if ($lang === $default) {
echo ' (' . esc_html__('def', 'ai-translator-for-elementor-and-polylang') . ')';
}
echo '';
} else {
echo '—';
}
}
/**
* Output CSS to constrain the language column width
*/
public function language_column_css() {
echo '';
}
/**
* Add a language filter dropdown to the admin posts/pages list.
*/
public function admin_language_filter_dropdown() {
if (!$this->is_active()) {
return;
}
global $typenow;
if (!in_array($typenow, ['post', 'page'], true)) {
return;
}
$languages = $this->get_stored_languages();
if (empty($languages)) {
return;
}
$selected = isset($_GET['aitrfoel_lang']) ? sanitize_text_field($_GET['aitrfoel_lang']) : '';
$default_lang = $this->get_default_language();
$default_name = strtoupper($default_lang);
foreach ($languages as $lang) {
if ($lang['code'] === $default_lang) {
$default_name = $lang['name'];
break;
}
}
// Preserve post_status when changing language filter (e.g. in Trash view)
$post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : '';
// In Trash with no explicit language filter, default to "All Languages"
if ($post_status === 'trash' && $selected === '') {
$selected = 'all';
}
if ($post_status) {
echo '';
}
echo '';
}
/**
* Filter admin post queries by selected language.
*/
public function admin_filter_posts_by_language($query) {
if (!$this->is_active() || !is_admin() || !$query->is_main_query()) {
return;
}
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || $screen->base !== 'edit') {
return;
}
$lang = isset($_GET['aitrfoel_lang']) ? sanitize_text_field($_GET['aitrfoel_lang']) : '';
if ($lang === 'all') {
// Show everything, no filtering
return;
}
if (!empty($lang) && $this->language_exists($lang)) {
// Specific language selected — filter by that taxonomy term
$tax_query = $query->get('tax_query') ?: [];
$tax_query[] = [
'taxonomy' => self::TAXONOMY,
'field' => 'slug',
'terms' => $lang,
'include_children' => false,
];
$query->set('tax_query', $tax_query);
return;
}
// In Trash view with no language filter, show all languages by default
$post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : '';
if ($post_status === 'trash') {
return;
}
// Default view (no param): exclude non-default language posts
// This hides translations while showing default-language posts
// (including untagged legacy posts)
$non_default_langs = [];
$default_lang = $this->get_default_language();
foreach ($this->get_stored_languages() as $l) {
if ($l['code'] !== $default_lang) {
$non_default_langs[] = $l['code'];
}
}
if (!empty($non_default_langs)) {
$tax_query = $query->get('tax_query') ?: [];
$tax_query[] = [
'taxonomy' => self::TAXONOMY,
'field' => 'slug',
'terms' => $non_default_langs,
'operator' => 'NOT IN',
'include_children' => false,
];
$query->set('tax_query', $tax_query);
}
}
/**
* Adjust post status view counts when a language filter is active.
* Without this, the All/Published/Draft/Trash counts show totals across all languages.
*
* @param array $views Associative array of status links (HTML).
* @return array Modified views with corrected counts.
*/
public function filter_post_status_counts_by_language($views) {
if (!$this->is_active()) {
return $views;
}
$lang = isset($_GET['aitrfoel_lang']) ? sanitize_text_field($_GET['aitrfoel_lang']) : '';
$post_status = isset($_GET['post_status']) ? sanitize_text_field($_GET['post_status']) : '';
// Only adjust counts when filtering by a specific language or default view
if ($lang === 'all') {
return $views;
}
global $typenow;
$post_type = $typenow ?: 'post';
$default_lang = $this->get_default_language();
// In Trash with no lang filter, show all — no need to adjust
if ($post_status === 'trash' && empty($lang)) {
return $views;
}
// Build the tax_query for counting
if (!empty($lang) && $this->language_exists($lang)) {
// Specific language: count posts with this taxonomy term
$tax_query = [
[
'taxonomy' => self::TAXONOMY,
'field' => 'slug',
'terms' => $lang,
'include_children' => false,
],
];
} else {
// Default view: count posts NOT in non-default languages
$non_default_langs = [];
foreach ($this->get_stored_languages() as $l) {
if ($l['code'] !== $default_lang) {
$non_default_langs[] = $l['code'];
}
}
if (empty($non_default_langs)) {
return $views;
}
$tax_query = [
[
'taxonomy' => self::TAXONOMY,
'field' => 'slug',
'terms' => $non_default_langs,
'operator' => 'NOT IN',
'include_children' => false,
],
];
}
// Count posts per status with language filter
$statuses = ['publish', 'draft', 'pending', 'trash', 'private'];
$counts = [];
$total = 0;
foreach ($statuses as $status) {
$q = new \WP_Query([
'post_type' => $post_type,
'post_status' => $status,
'tax_query' => $tax_query,
'posts_per_page' => -1,
'fields' => 'ids',
'no_found_rows' => false,
]);
$counts[$status] = $q->found_posts;
if ($status !== 'trash') {
$total += $q->found_posts;
}
}
// Update view links with corrected counts
foreach ($views as $key => &$html) {
if ($key === 'all') {
$html = preg_replace('/\(\(?\d+\)?\s*<\/span>\)/', '((' . $total . '))', $html);
// Also fix plain count format
$html = preg_replace('/class="count">\(\d+\)/', 'class="count">(' . $total . ')', $html);
} elseif (isset($counts[$key])) {
$html = preg_replace('/class="count">\(\d+\)/', 'class="count">(' . $counts[$key] . ')', $html);
}
// Remove status links with zero count (except current)
if ($key !== 'all' && isset($counts[$key]) && $counts[$key] === 0) {
unset($views[$key]);
}
}
unset($html);
return $views;
}
// =========================================================================
// CLEANUP
// =========================================================================
/**
* Remove all built-in language data (for uninstall)
*/
public static function uninstall() {
global $wpdb;
// Drop translation groups table
$table = $wpdb->prefix . self::TABLE_NAME;
$wpdb->query("DROP TABLE IF EXISTS {$table}");
// Remove options
delete_option(self::OPTION_LANGUAGES);
delete_option(self::OPTION_DEFAULT_LANG);
delete_option(self::OPTION_ENABLED);
delete_option(self::OPTION_DB_VERSION);
delete_option(self::OPTION_URL_STRUCTURE);
// Remove taxonomy terms and associations
$terms = get_terms(['taxonomy' => self::TAXONOMY, 'hide_empty' => false, 'fields' => 'ids']);
if (!is_wp_error($terms)) {
foreach ($terms as $term_id) {
wp_delete_term($term_id, self::TAXONOMY);
}
}
}
// =========================================================================
// LANGUAGE SWITCHER WIDGET & SHORTCODE
// =========================================================================
/**
* Register the language switcher shortcode
*/
public function register_shortcode() {
add_shortcode('aitrfoel_language_switcher', [$this, 'render_language_switcher_shortcode']);
}
/**
* Register the language switcher widget
*/
public function register_widget() {
register_widget('AITRFOEL_Language_Switcher_Widget');
}
/**
* Append language switcher links to a nav menu if the theme location
* is configured via the 'aitrfoel_nav_menu_switcher' option.
*
* Enable with: update_option('aitrfoel_nav_menu_switcher', true);
*
* @param string $items HTML menu items.
* @param stdClass $args Menu display arguments.
* @return string
*/
public function add_language_switcher_to_menu($items, $args) {
if (!$this->is_active()) {
return $items;
}
if (!get_option('aitrfoel_switcher_enabled', true)) {
return $items;
}
// Only add to menus if enabled via option
$enabled = get_option('aitrfoel_nav_menu_switcher', false);
if (!$enabled) {
return $items;
}
// Optionally restrict to specific menu location
$location = get_option('aitrfoel_nav_menu_location', '');
if ($location && $args->theme_location !== $location) {
return $items;
}
$switcher_data = $this->get_language_switcher_data();
if (empty($switcher_data)) {
return $items;
}
$show_flags = get_option('aitrfoel_switcher_show_flags', true);
$show_names = get_option('aitrfoel_switcher_show_names', true);
foreach ($switcher_data as $lang) {
$class = 'menu-item menu-item-language';
if ($lang['is_current']) {
$class .= ' current-language';
}
$flag_html = $show_flags ? AITRFOEL_Language_Mapper::get_flag_html($lang['code'], 16, 'margin-right: 4px;') : '';
$name_html = $show_names ? esc_html($this->get_language_display_name($lang)) : '';
if (!$show_flags && !$show_names) {
$name_html = esc_html(strtoupper($lang['code']));
}
$items .= sprintf(
'