'1', 'elementor_pages' => '1', 'menus' => '1', 'seo_title' => '1', 'seo_description' => '1', 'posts' => '0', 'products' => '0', 'widgets' => '0', 'taxonomies' => '0', 'image_alt' => '0', 'image_caption' => '0', 'custom_fields' => '0', 'elementor_library' => '0', 'sectionbuilder' => '0', 'litho-mega-menu' => '0', 'other_public_cpt' => '0', ); } public static function selection($options = array()) { $options = is_array($options) ? $options : array(); $saved = isset($options['scanner_sources']) && is_array($options['scanner_sources']) ? $options['scanner_sources'] : array(); return wp_parse_args($saved, self::defaults()); } public static function sanitize($input) { $clean = array(); foreach (self::catalog() as $source) { $key = (string) $source['key']; $clean[$key] = !empty($input[$key]) ? '1' : '0'; } return wp_parse_args($clean, self::defaults()); } public static function catalog() { $catalog = array( self::item('pages', __('Pages', 'ait-multilingual-translate'), 'frontend', __('Published WordPress pages.', 'ait-multilingual-translate'), self::post_count('page')), self::item('posts', __('Posts', 'ait-multilingual-translate'), 'frontend', __('Published blog and news posts.', 'ait-multilingual-translate'), self::post_count('post')), self::item('products', __('Products', 'ait-multilingual-translate'), 'frontend', __('Published WooCommerce product titles, descriptions, and selected visible attributes.', 'ait-multilingual-translate'), self::post_count('product')), self::item('elementor_pages', __('Elementor Page Content', 'ait-multilingual-translate'), 'frontend', __('Visible widget fields from published Elementor pages.', 'ait-multilingual-translate'), self::elementor_page_count()), self::item('menus', __('Navigation Menus', 'ait-multilingual-translate'), 'frontend_shared', __('Shared menu labels visible on the frontend.', 'ait-multilingual-translate'), self::menu_count()), self::item('widgets', __('Widgets', 'ait-multilingual-translate'), 'frontend_shared', __('Visible text from active widget instances.', 'ait-multilingual-translate'), self::widget_count()), self::item('litho-mega-menu', __('Litho Mega Menu', 'ait-multilingual-translate'), 'frontend_shared', __('Published reusable mega menu content.', 'ait-multilingual-translate'), self::post_count('litho-mega-menu')), self::item('elementor_library', __('Elementor Library', 'ait-multilingual-translate'), 'template', __('Saved Elementor templates. Enable only when they are used on the frontend.', 'ait-multilingual-translate'), self::post_count('elementor_library')), self::item('sectionbuilder', __('Section Builder', 'ait-multilingual-translate'), 'template', __('Reusable section templates.', 'ait-multilingual-translate'), self::post_count('sectionbuilder')), self::item('seo_title', __('SEO Title', 'ait-multilingual-translate'), 'technical', __('SEO titles from supported SEO plugins.', 'ait-multilingual-translate'), 0), self::item('seo_description', __('SEO Meta Description', 'ait-multilingual-translate'), 'technical', __('SEO descriptions from supported SEO plugins.', 'ait-multilingual-translate'), 0), self::item('taxonomies', __('Taxonomies', 'ait-multilingual-translate'), 'metadata', __('Public term names and descriptions.', 'ait-multilingual-translate'), self::taxonomy_count()), self::item('image_alt', __('Image Alt Text', 'ait-multilingual-translate'), 'metadata', __('Alternative text for published media used by content.', 'ait-multilingual-translate'), 0), self::item('image_caption', __('Image Captions', 'ait-multilingual-translate'), 'metadata', __('Published image captions.', 'ait-multilingual-translate'), 0), self::item('custom_fields', __('Custom Fields', 'ait-multilingual-translate'), 'technical', __('Public human-readable custom fields. Keep disabled unless required.', 'ait-multilingual-translate'), 0), self::item('other_public_cpt', __('Other Public Post Types', 'ait-multilingual-translate'), 'frontend', __('Published content from other public post types.', 'ait-multilingual-translate'), 0), ); foreach (get_post_types(array('public' => true), 'objects') as $type => $object) { if (in_array($type, array('post', 'page', 'product', 'attachment', 'elementor_library', 'sectionbuilder', 'litho-mega-menu'), true)) { continue; } $catalog[] = self::item( 'cpt_' . sanitize_key($type), /* translators: %s: singular post type label. */ sprintf(__('Post type: %s', 'ait-multilingual-translate'), $object->labels->singular_name), 'frontend', /* translators: %s: plural post type label. */ sprintf(__('Published %s content.', 'ait-multilingual-translate'), $object->labels->name), self::post_count($type), $type ); } return $catalog; } private static function item($key, $label, $classification, $description, $count, $post_type = '') { return array( 'key' => sanitize_key($key), 'label' => (string) $label, 'classification' => sanitize_key($classification), 'description' => (string) $description, 'count' => absint($count), 'post_type' => sanitize_key($post_type), ); } public static function collect($options = array()) { $selected = self::selection($options); $buckets = array(); $post_types = array(); if ('1' === $selected['pages'] || '1' === $selected['elementor_pages']) { $post_types['page'] = 'page'; } if ('1' === $selected['posts']) { $post_types['post'] = 'post'; } if ('1' === ($selected['products'] ?? '0') && post_type_exists('product')) { $post_types['product'] = 'product'; } foreach (self::catalog() as $source) { if (0 === strpos($source['key'], 'cpt_') && '1' === ($selected[$source['key']] ?? '0')) { $post_types[$source['post_type']] = $source['post_type']; } } if ('1' === $selected['other_public_cpt']) { foreach (get_post_types(array('public' => true), 'names') as $post_type) { if (!in_array($post_type, array('post', 'page', 'product', 'attachment', 'elementor_library', 'sectionbuilder', 'litho-mega-menu'), true)) { $post_types[$post_type] = $post_type; } } } foreach (array('elementor_library', 'sectionbuilder', 'litho-mega-menu') as $template_type) { if ('1' === ($selected[$template_type] ?? '0')) { $post_types[$template_type] = $template_type; } } foreach (array_keys($post_types) as $post_type) { $ids = get_posts(array( 'post_type' => $post_type, 'post_status' => 'publish', 'numberposts' => -1, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'suppress_filters' => false, )); foreach ($ids as $post_id) { foreach (self::collect_post($post_id, $options) as $source_type => $items) { if (!isset($buckets[$source_type])) { $buckets[$source_type] = array(); } $buckets[$source_type] = array_replace($buckets[$source_type], $items); } } } if ('1' === $selected['menus']) { foreach (wp_get_nav_menus() as $menu) { foreach (wp_get_nav_menu_items($menu->term_id, array('post_status' => 'publish')) ?: array() as $item) { aitmt_fallback_add_scan_source_text($buckets, 'menu', $item->title, $item->ID, 'menu_title'); aitmt_fallback_add_scan_source_text($buckets, 'menu', $item->attr_title, $item->ID, 'menu_attr_title'); aitmt_fallback_add_scan_source_text($buckets, 'menu', $item->description, $item->ID, 'menu_description'); } } } if ('1' === $selected['widgets']) { foreach ((array) get_option('sidebars_widgets', array()) as $sidebar => $widgets) { if ('wp_inactive_widgets' === $sidebar || !is_array($widgets)) { continue; } foreach ($widgets as $widget_id) { if (!preg_match('/^(.+)-(\d+)$/', $widget_id, $match)) { continue; } $settings = get_option('widget_' . $match[1], array()); $units = array(); self::walk_settings($settings[(int) $match[2]] ?? array(), $units, array('id' => $widget_id, 'type' => $match[1]), array('settings')); foreach ($units as $unit) { aitmt_fallback_add_scan_source_text($buckets, 'widget', $unit['text'], 0, $unit['field_key']); } } } } if ('1' === $selected['taxonomies']) { foreach (get_taxonomies(array('public' => true), 'names') as $taxonomy) { foreach (get_terms(array('taxonomy' => $taxonomy, 'hide_empty' => false)) as $term) { if (is_wp_error($term)) { continue; } aitmt_fallback_add_scan_source_text($buckets, 'taxonomy', $term->name, $term->term_id, 'term_name'); aitmt_fallback_add_scan_source_html($buckets, 'taxonomy', $term->description, $term->term_id, 'term_description'); } } } return self::apply_exclusions($buckets, $options); } private static function collect_meta(&$buckets, $post_id, $selected) { $keys = array(); if ('1' === $selected['seo_title']) { $keys = array_merge($keys, array('_yoast_wpseo_title', 'rank_math_title', '_seopress_titles_title')); } if ('1' === $selected['seo_description']) { $keys = array_merge($keys, array('_yoast_wpseo_metadesc', 'rank_math_description', '_seopress_titles_desc')); } if ('1' === $selected['image_alt']) { $keys[] = '_wp_attachment_image_alt'; } foreach (array_unique($keys) as $key) { $value = get_post_meta($post_id, $key, true); if (is_string($value)) { aitmt_fallback_add_scan_source_text($buckets, 'page_meta', $value, $post_id, $key); } } if ('1' === $selected['image_alt'] || '1' === $selected['image_caption']) { foreach (get_attached_media('image', $post_id) as $attachment) { if ('1' === $selected['image_alt']) { aitmt_fallback_add_scan_source_text($buckets, 'page_meta', get_post_meta($attachment->ID, '_wp_attachment_image_alt', true), $attachment->ID, 'image_alt'); } if ('1' === $selected['image_caption']) { aitmt_fallback_add_scan_source_text($buckets, 'page_meta', $attachment->post_excerpt, $attachment->ID, 'image_caption'); } } } if ('1' === $selected['custom_fields']) { foreach (get_post_meta($post_id) as $key => $values) { if ('_' === substr((string) $key, 0, 1) || false !== strpos((string) $key, 'elementor')) { continue; } foreach ((array) $values as $value) { if (is_string($value) && !is_serialized($value)) { aitmt_fallback_add_scan_source_text($buckets, 'page_meta', $value, $post_id, (string) $key); } } } } } public static function elementor_units($post_id) { $raw = get_post_meta(absint($post_id), '_elementor_data', true); $data = is_array($raw) ? $raw : json_decode((string) $raw, true); return self::elementor_units_from_data($data); } public static function is_elementor_built($post_id) { if ('builder' === get_post_meta($post_id, '_elementor_edit_mode', true)) { return true; } if (class_exists('\\Elementor\\Plugin')) { $manager = \Elementor\Plugin::instance()->documents; $document = $manager ? $manager->get($post_id) : null; if ($document && method_exists($document, 'is_built_with_elementor') && $document->is_built_with_elementor()) { return true; } } $raw = get_post_meta($post_id, '_elementor_data', true); $data = is_array($raw) ? $raw : json_decode((string) $raw, true); return is_array($data) && !empty($data); } public static function is_flattened_elementor_row($row, $options) { $selected = self::selection($options); $type = (string) ($row['source_type'] ?? ''); $enabled = ('page' === $type && '1' === ($selected['elementor_pages'] ?? '0')) || (in_array($type, array('elementor_library', 'sectionbuilder', 'litho-mega-menu'), true) && '1' === ($selected[$type] ?? '0')); return $enabled && 0 === strpos((string) ($row['field_key'] ?? ''), 'post_content') && self::is_elementor_built(absint($row['object_id'] ?? 0)); } /** * Convert decoded Elementor JSON to one translation unit per visible field. * * @param array $data Decoded Elementor document. * @return array> */ public static function elementor_units_from_data($data) { if (!is_array($data)) { return array(); } $units = array(); self::walk_elements($data, $units, array()); return array_values($units); } private static function walk_elements($elements, &$units, $context) { foreach ((array) $elements as $element) { if (!is_array($element)) { continue; } $id = sanitize_key((string) ($element['id'] ?? '')); $type = sanitize_key((string) ($element['widgetType'] ?? $element['elType'] ?? 'element')); $current = array('id' => $id, 'type' => $type); if (!empty($element['settings']) && is_array($element['settings'])) { self::walk_settings($element['settings'], $units, $current, array('settings')); } if (!empty($element['elements']) && is_array($element['elements'])) { self::walk_elements($element['elements'], $units, $current); } } } private static function walk_settings($value, &$units, $context, $path, $depth = 0) { if ($depth > 12) { return; } if (is_array($value)) { foreach ($value as $key => $child) { $part = is_array($child) && !empty($child['_id']) ? 'row-' . sanitize_key($child['_id']) : (string) $key; self::walk_settings($child, $units, $context, array_merge($path, array($part)), $depth + 1); } return; } if (!is_string($value)) { return; } $field = sanitize_key((string) end($path)); if (!self::human_field($field)) { return; } $texts = self::visible_text_units($value); foreach ($texts as $index => $text) { $field_key = sprintf( 'widget:%s:%s:%s:%d', $context['id'], $context['type'], implode('.', array_map('sanitize_key', $path)), $index ); $hash = aitmt_fallback_translation_hash($text); $units[$hash . '|' . $field_key] = array( 'text' => $text, 'widget_id' => $context['id'], 'widget_type' => $context['type'], 'field' => $field, 'field_key' => aitmt_fallback_normalize_field_key($field_key), ); } } private static function human_field($field) { $allowed = array( 'title', 'text', 'editor', 'content', 'description', 'subtitle', 'heading', 'subheading', 'caption', 'alt', 'label', 'placeholder', 'button_text', 'link_text', 'tab_title', 'accordion_title', 'item_title', 'testimonial_content', 'name', 'period', 'currency', 'price', 'submit_text', 'field_label', 'field_placeholder', 'option', 'html', ); if (in_array($field, $allowed, true)) { return true; } return (bool) preg_match('/(?:^|_)(title|text|content|description|label|placeholder|caption|heading|name)$/', $field); } private static function visible_text_units($value) { $value = (string) $value; if ('' === trim($value)) { return array(); } if (false === strpos($value, '<')) { return aitmt_fallback_is_translatable_text($value) ? array(aitmt_fallback_normalize_text($value)) : array(); } $clean = preg_replace('#<(script|style|svg)[^>]*>.*?#is', ' ', $value); $clean = preg_replace('#|#i', "\n", (string) $clean); $clean = wp_strip_all_tags((string) $clean); $parts = preg_split('/[\r\n]+/', html_entity_decode((string) $clean, ENT_QUOTES | ENT_HTML5, 'UTF-8')); $result = array(); foreach ((array) $parts as $part) { $part = aitmt_fallback_normalize_text($part); if (aitmt_fallback_is_translatable_text($part)) { $result[$part] = $part; } } return array_values($result); } public static function collect_post($post_id, $options = array()) { $post_id = absint($post_id); $post = get_post($post_id); if (!self::is_published($post)) { return array(); } $selected = self::selection($options); $post_type = sanitize_key((string) $post->post_type); $source_type = self::source_type_for_post_type($post_type); if (self::object_excluded($source_type, $post_id, $options)) { return array(); } $include_base = self::source_enabled_for_post_type($post_type, $selected); $include_elementor = ('page' === $post_type && '1' === ($selected['elementor_pages'] ?? '0')) || (in_array($post_type, array('elementor_library', 'sectionbuilder', 'litho-mega-menu'), true) && $include_base); if (!$include_base && !$include_elementor) { return array(); } $buckets = array(); if ($include_base) { aitmt_fallback_add_scan_source_text($buckets, $source_type, get_the_title($post_id), $post_id, 'post_title'); aitmt_fallback_add_scan_source_text($buckets, $source_type, get_post_field('post_excerpt', $post_id), $post_id, 'post_excerpt'); if (!$include_elementor || !self::is_elementor_built($post_id)) { aitmt_fallback_add_scan_source_html($buckets, $source_type, get_post_field('post_content', $post_id), $post_id, 'post_content'); } if ('product' === $post_type && function_exists('wc_get_product')) { $product = wc_get_product($post_id); if ($product) { foreach ((array) $product->get_attributes() as $attribute) { if (!is_object($attribute) || !method_exists($attribute, 'get_name') || !method_exists($attribute, 'get_options')) { continue; } $attribute_name = wc_attribute_label($attribute->get_name(), $product); aitmt_fallback_add_scan_source_text($buckets, 'product', $attribute_name, $post_id, 'product_attribute_name'); foreach ((array) $attribute->get_options() as $attribute_option) { $attribute_text = is_numeric($attribute_option) ? get_term_field('name', absint($attribute_option), $attribute->get_name()) : $attribute_option; if (!is_wp_error($attribute_text)) { aitmt_fallback_add_scan_source_text($buckets, 'product', (string) $attribute_text, $post_id, 'product_attribute_value'); } } } } } } if ($include_elementor) { $elementor_source = 'page' === $post_type ? 'elementor' : $source_type; foreach (self::elementor_units($post_id) as $unit) { aitmt_fallback_add_scan_source_text($buckets, $elementor_source, $unit['text'], $post_id, $unit['field_key']); } } self::collect_meta($buckets, $post_id, $selected); return self::apply_exclusions($buckets, $options); } public static function queue_row_enabled($row, $options = array()) { $row = is_array($row) ? $row : array(); $source_type = aitmt_fallback_normalize_source_type($row['source_type'] ?? ''); if ('' === $source_type) { return false; } if (in_array($source_type, array('frontend_editor', 'manual', 'import', 'provider', 'shortcode', 'runtime'), true)) { return true; } if ('page_load' === $source_type) { $options = is_array($options) ? $options : array(); return '1' === ($options['translate_on_page_load'] ?? '0'); } $selected = self::selection($options); if (self::is_excluded($source_type, absint($row['object_id'] ?? 0), (string) ($row['field_key'] ?? ''), $options)) { return false; } if (self::is_flattened_elementor_row($row, $options)) { return false; } $map = array( 'page' => 'pages', 'post' => 'posts', 'elementor' => 'elementor_pages', 'menu' => 'menus', 'widget' => 'widgets', 'taxonomy' => 'taxonomies', 'elementor_library' => 'elementor_library', 'sectionbuilder' => 'sectionbuilder', 'litho-mega-menu' => 'litho-mega-menu', ); if (isset($map[$source_type]) && '1' !== ($selected[$map[$source_type]] ?? '0')) { return false; } $object_id = absint($row['object_id'] ?? 0); if ($object_id > 0 && !in_array($source_type, array('menu', 'taxonomy'), true)) { $post = get_post($object_id); if (!self::is_published($post)) { return false; } } if ('page_meta' === $source_type) { $field = (string) ($row['field_key'] ?? ''); if (in_array($field, array('_yoast_wpseo_title', 'rank_math_title', '_seopress_titles_title'), true)) { return '1' === ($selected['seo_title'] ?? '0'); } if (in_array($field, array('_yoast_wpseo_metadesc', 'rank_math_description', '_seopress_titles_desc'), true)) { return '1' === ($selected['seo_description'] ?? '0'); } if ('_wp_attachment_image_alt' === $field) { return '1' === ($selected['image_alt'] ?? '0'); } if ('image_caption' === $field) { return '1' === ($selected['image_caption'] ?? '0'); } return '1' === ($selected['custom_fields'] ?? '0'); } if (!isset($map[$source_type]) && $object_id > 0) { $post_type = sanitize_key((string) get_post_type($object_id)); return self::source_enabled_for_post_type($post_type, $selected); } return isset($map[$source_type]); } public static function context_key($source_type, $object_id, $field_key) { return hash('sha256', sanitize_key((string) $source_type) . '|' . absint($object_id) . '|' . aitmt_fallback_normalize_field_key($field_key)); } public static function object_key($source_type, $object_id) { return ('taxonomy' === $source_type ? 'term:' : 'post:') . absint($object_id); } public static function object_excluded($source_type, $object_id, $options) { return $object_id > 0 && !empty($options['scanner_object_exclusions'][self::object_key($source_type, $object_id)]); } private static function exclusions($options) { $options = is_array($options) ? $options : array(); $items = isset($options['scanner_exclusions']) && is_array($options['scanner_exclusions']) ? $options['scanner_exclusions'] : array(); return array_fill_keys(array_map('sanitize_key', array_keys(array_filter($items))), true); } public static function is_excluded($source_type, $object_id, $field_key, $options) { $exclusions = self::exclusions($options); // Continue honoring exclusions saved with the pre-0.4.2 field sanitizer. $legacy_key = self::context_key($source_type, $object_id, substr(sanitize_key($field_key), 0, 120)); return self::object_excluded($source_type, $object_id, $options) || isset($exclusions[self::context_key($source_type, $object_id, $field_key)]) || isset($exclusions[$legacy_key]); } private static function apply_exclusions($buckets, $options) { $exclusions = self::exclusions($options); if (empty($exclusions) && empty($options['scanner_object_exclusions'])) { return $buckets; } foreach ((array) $buckets as $source_type => $items) { foreach ((array) $items as $key => $item) { if (self::is_excluded($source_type, $item['object_id'] ?? 0, $item['field_key'] ?? '', $options)) { unset($buckets[$source_type][$key]); } } if (empty($buckets[$source_type])) { unset($buckets[$source_type]); } } return $buckets; } public static function handle_preview_exclusions() { if (!current_user_can('manage_options')) { wp_die(esc_html__('Permission denied.', 'ait-multilingual-translate')); } check_admin_referer('aitmt_save_scanner_exclusions'); $options = get_option('aitmt_options', array()); $options = is_array($options) ? $options : array(); $existing = isset($options['scanner_exclusions']) && is_array($options['scanner_exclusions']) ? $options['scanner_exclusions'] : array(); $displayed = isset($_POST['preview_keys']) && is_array($_POST['preview_keys']) ? array_map('sanitize_key', wp_unslash($_POST['preview_keys'])) : array(); $included = isset($_POST['preview_included']) && is_array($_POST['preview_included']) ? array_fill_keys(array_map('sanitize_key', wp_unslash($_POST['preview_included'])), true) : array(); foreach ($displayed as $key) { if (isset($included[$key])) { unset($existing[$key]); } else { $existing[$key] = '1'; } } $options['scanner_exclusions'] = $existing; update_option('aitmt_options', $options, false); update_option('aitmt_scanner_sources_notice', __('Scanner preview exclusions saved.', 'ait-multilingual-translate'), false); wp_safe_redirect(admin_url('admin.php?page=ait-multilingual-translate-scanner')); exit; } public static function manage_exclusions($options, $operation, $rows) { foreach ($rows as $row) { $key = self::context_key($row['source_type'], $row['object_id'], $row['field_key']); $legacy_key = self::context_key($row['source_type'], $row['object_id'], substr(sanitize_key($row['field_key']), 0, 120)); if ('exclude' === $operation) { $options['scanner_exclusions'][$key] = '1'; } elseif ('include' === $operation) { unset($options['scanner_exclusions'][$key], $options['scanner_exclusions'][$legacy_key]); } elseif ($row['object_id'] > 0) { $object_key = self::object_key($row['source_type'], $row['object_id']); if ('exclude_object' === $operation) { $options['scanner_object_exclusions'][$object_key] = '1'; } elseif ('include_object' === $operation) { unset($options['scanner_object_exclusions'][$object_key]); } } } return $options; } public static function sanitize_exclusions($items, $objects = false) { $clean = array(); foreach ((array) $items as $key => $enabled) { $pattern = $objects ? '/^(post|term):[1-9][0-9]*$/' : '/^[a-f0-9]{64}$/'; if (is_scalar($enabled) && !empty($enabled) && preg_match($pattern, (string) $key)) { $clean[(string) $key] = '1'; } } return $clean; } /** Remove only empty queued jobs owned by the selected scanner fields. */ public static function remove_queued_units($rows) { global $wpdb; $lock = aitmt_fallback_acquire_queue_lock('scanner_remove'); if (is_wp_error($lock)) { return $lock; } $removed = 0; try { $table = aitmt_fallback_translation_table_sql(); if ('' === $table) { return new WP_Error('aitmt_invalid_table', __('Translation table unavailable.', 'ait-multilingual-translate')); } foreach ($rows as $row) { $field = aitmt_fallback_normalize_field_key($row['field_key']); $legacy_field = substr(sanitize_key($row['field_key']), 0, 120); $ids = $wpdb->get_col($wpdb->prepare( "SELECT id FROM {$table} WHERE source_type = %s AND object_id = %d AND field_key IN (%s, %s) AND source_language = %s AND context = 'html' AND status = 'queued' AND translated_text = '' AND provider NOT IN ('manual', 'import') ORDER BY id LIMIT %d", $row['source_type'], $row['object_id'], $field, $legacy_field, aitmt_fallback_source_language(), 1000 - $removed )); if ($wpdb->last_error) { return new WP_Error('aitmt_queue_remove', __('Queue removal failed. No saved translations were removed.', 'ait-multilingual-translate')); } foreach ($ids as $id) { $result = $wpdb->delete($table, array('id' => absint($id), 'status' => 'queued', 'translated_text' => ''), array('%d', '%s', '%s')); if (false === $result) { return new WP_Error('aitmt_queue_remove', __('Queue removal failed. No saved translations were removed.', 'ait-multilingual-translate')); } $removed += (int) $result; } if ($removed >= 1000) { break; } } return $removed; } finally { aitmt_fallback_release_queue_lock($lock); } } public static function handle_preview_management() { if (!current_user_can('manage_options')) { wp_die(esc_html__('Permission denied.', 'ait-multilingual-translate')); } check_admin_referer('aitmt_manage_scanner_preview'); $action = isset($_POST['preview_action']) ? sanitize_text_field(wp_unslash($_POST['preview_action'])) : ''; $parts = explode('|', $action, 2); $operation = sanitize_key($parts[0]); $keys = isset($parts[1]) ? array(sanitize_key($parts[1])) : array(); if ('bulk' === $operation) { $operation = isset($_POST['bulk_operation']) ? sanitize_key(wp_unslash($_POST['bulk_operation'])) : ''; $keys = isset($_POST['preview_selected']) && is_array($_POST['preview_selected']) ? array_map('sanitize_key', wp_unslash($_POST['preview_selected'])) : array(); } if (!in_array($operation, array('exclude', 'include', 'remove', 'exclude_object', 'include_object'), true) || count($keys) > 100) { wp_die(esc_html__('Invalid scanner operation.', 'ait-multilingual-translate')); } $options = get_option('aitmt_options', array()); $keys = array_fill_keys($keys, true); // Resolve submitted identities against the current source manifest, never client-supplied SQL or text. $rows = array_filter(self::preview($options, 0), static function ($row) use ($keys) { return isset($keys[$row['context_key']]); }); if ('remove' === $operation) { $result = self::remove_queued_units($rows); /* translators: %d: number of deleted empty queued jobs. */ $message = is_wp_error($result) ? $result->get_error_message() : sprintf(__('Removed %d queued jobs. Saved translations and scanner exclusions are unchanged. Maximum 1000 jobs per action.', 'ait-multilingual-translate'), $result); } else { update_option('aitmt_options', self::manage_exclusions($options, $operation, $rows), false); $message = __('Scanner exclusions saved. Existing translations were preserved. Object exclusions override individual field includes.', 'ait-multilingual-translate'); } update_option('aitmt_scanner_sources_notice', $message, false); wp_safe_redirect(wp_get_referer() ?: admin_url('admin.php?page=ait-multilingual-translate-scanner')); exit; } /** * Decide whether a post object can be scanned or sent to a provider. * * @param object|null $post Post-like object. * @return bool */ public static function is_published($post) { return is_object($post) && isset($post->post_status) && 'publish' === (string) $post->post_status; } /** * Resolve the Scanner Sources switch for a post type. * * @param string $post_type Post type slug. * @param array $selected Sanitized source selection. * @return bool */ public static function source_enabled_for_post_type($post_type, $selected) { if ('page' === $post_type) { return '1' === ($selected['pages'] ?? '0'); } if ('post' === $post_type) { return '1' === ($selected['posts'] ?? '0'); } if ('product' === $post_type) { return '1' === ($selected['products'] ?? '0'); } if (in_array($post_type, array('elementor_library', 'sectionbuilder', 'litho-mega-menu'), true)) { return '1' === ($selected[$post_type] ?? '0'); } return '1' === ($selected['cpt_' . sanitize_key($post_type)] ?? '0') || '1' === ($selected['other_public_cpt'] ?? '0'); } public static function preview($options = array(), $limit = 200) { $rows = array(); $preview_options = is_array($options) ? $options : array(); $preview_options['scanner_exclusions'] = array(); $preview_options['scanner_object_exclusions'] = array(); foreach (self::collect($preview_options) as $source_type => $items) { foreach ($items as $item) { $rows[] = array( 'source_type' => $source_type, 'classification' => self::classification($source_type), 'object_id' => absint($item['object_id'] ?? 0), 'object' => self::object_label($source_type, absint($item['object_id'] ?? 0)), 'field_key' => (string) ($item['field_key'] ?? ''), 'text' => (string) ($item['text'] ?? ''), 'characters' => aitmt_fallback_text_length((string) ($item['text'] ?? '')), 'context_key' => self::context_key($source_type, absint($item['object_id'] ?? 0), (string) ($item['field_key'] ?? '')), 'included' => !self::is_excluded($source_type, absint($item['object_id'] ?? 0), (string) ($item['field_key'] ?? ''), $options), 'object_key' => self::object_key($source_type, absint($item['object_id'] ?? 0)), 'object_excluded' => self::object_excluded($source_type, absint($item['object_id'] ?? 0), $options), ); if ($limit > 0 && count($rows) >= $limit) { break 2; } } } $statuses = self::translation_statuses($rows); foreach ($rows as &$row) { $status = $statuses[aitmt_fallback_translation_hash($row['text'])] ?? array( 'key' => 'missing', 'label' => __('New', 'ait-multilingual-translate'), ); $row['translation_status_key'] = $status['key']; $row['translation_status'] = $status['label']; $row['translation_status_keys'] = $status['keys'] ?? array($status['key']); } unset($row); return $rows; } private static function translation_statuses($rows) { $texts = array(); foreach ($rows as $row) { $texts[aitmt_fallback_translation_hash($row['text'])] = $row['text']; } $targets = array_values(array_diff(aitmt_fallback_enabled_languages(), array(aitmt_fallback_source_language()))); $counts = array_fill_keys(array_keys($texts), 0); foreach ($targets as $target) { $existing = aitmt_fallback_get_existing_translations(array_values($texts), aitmt_fallback_source_language(), $target, true, true); foreach (array_keys($existing) as $hash) { if (isset($counts[$hash])) { $counts[$hash]++; } } } $labels = array(); foreach ($counts as $hash => $count) { if (empty($targets) || 0 === $count) { $labels[$hash] = array('key' => 'missing', 'label' => __('New', 'ait-multilingual-translate')); } elseif ($count >= count($targets)) { $labels[$hash] = array('key' => 'complete', 'label' => __('Translated', 'ait-multilingual-translate')); } else { $labels[$hash] = array('key' => 'partial', 'label' => __('Partially translated', 'ait-multilingual-translate')); } } global $wpdb; if (!empty($texts) && !empty($targets) && aitmt_fallback_translation_table_exists()) { $table = aitmt_fallback_translation_table_sql(); if ('' !== $table) { foreach (array_chunk(array_keys($texts), 200) as $hashes) { $hash_placeholders = implode(',', array_fill(0, count($hashes), '%s')); $target_placeholders = implode(',', array_fill(0, count($targets), '%s')); $states = $wpdb->get_results($wpdb->prepare( "SELECT source_hash, status FROM {$table} WHERE source_language = %s AND source_hash IN ({$hash_placeholders}) AND target_language IN ({$target_placeholders}) GROUP BY source_hash, status", array_merge(array(aitmt_fallback_source_language()), $hashes, $targets) ), ARRAY_A); $known_labels = self::status_labels(); foreach ((array) $states as $state) { $hash = $state['source_hash']; if (!isset($labels[$hash]) || !isset($known_labels[$state['status']])) { continue; } if (!isset($labels[$hash]['keys'])) { $labels[$hash]['keys'] = array($labels[$hash]['key']); } $labels[$hash]['keys'][] = $state['status']; $labels[$hash]['label'] .= ' / ' . $known_labels[$state['status']]; } } } } return $labels; } public static function status_labels() { return array( 'missing' => __('Missing', 'ait-multilingual-translate'), 'partial' => __('Partial', 'ait-multilingual-translate'), 'complete' => __('Complete', 'ait-multilingual-translate'), 'queued' => __('Queued', 'ait-multilingual-translate'), 'processing' => __('Processing', 'ait-multilingual-translate'), 'failed' => __('Failed', 'ait-multilingual-translate'), 'published' => __('Published', 'ait-multilingual-translate'), 'manual' => __('Manual', 'ait-multilingual-translate'), 'import' => __('Imported', 'ait-multilingual-translate'), 'draft' => __('Draft', 'ait-multilingual-translate'), ); } public static function preview_filters($input) { $filters = array(); foreach (array('scanner_usage', 'scanner_source', 'scanner_object', 'scanner_status', 'scanner_inclusion', 'scanner_search') as $key) { $filters[$key] = isset($input[$key]) && is_scalar($input[$key]) ? sanitize_text_field(wp_unslash($input[$key])) : ''; } return $filters; } public static function filter_preview_rows($rows, $filters) { return array_values(array_filter($rows, static function ($row) use ($filters) { foreach (array('scanner_usage' => 'classification', 'scanner_source' => 'source_type', 'scanner_object' => 'object_key') as $filter => $field) { if (!empty($filters[$filter]) && $filters[$filter] !== $row[$field]) { return false; } } if (!empty($filters['scanner_inclusion']) && ('included' === $filters['scanner_inclusion']) !== (bool) $row['included']) { return false; } if (!empty($filters['scanner_status']) && !in_array($filters['scanner_status'], $row['translation_status_keys'], true)) { return false; } $haystack = $row['object'] . ' ' . $row['object_id'] . ' ' . $row['field_key'] . ' ' . $row['text']; $needle = $filters['scanner_search'] ?? ''; return '' === $needle || (function_exists('mb_stripos') ? false !== mb_stripos($haystack, $needle, 0, 'UTF-8') : false !== stripos($haystack, $needle)); })); } private static function object_label($source_type, $object_id) { if ('taxonomy' === $source_type) { $term = get_term($object_id); return $term && !is_wp_error($term) ? $term->name : ''; } return $object_id ? get_the_title($object_id) : __('Widgets', 'ait-multilingual-translate'); } private static function object_edit_url($row) { if ('taxonomy' === $row['source_type']) { $term = get_term($row['object_id']); return $term && !is_wp_error($term) ? get_edit_term_link($term->term_id, $term->taxonomy) : admin_url('edit-tags.php'); } return $row['object_id'] ? get_edit_post_link($row['object_id']) : admin_url('widgets.php'); } public static function classification($source_type) { if (in_array($source_type, array('menu', 'widget', 'litho-mega-menu'), true)) { return 'frontend_shared'; } if (in_array($source_type, array('elementor_library', 'sectionbuilder'), true)) { return 'template'; } if (in_array($source_type, array('page_meta', 'taxonomy'), true)) { return 'technical'; } return 'frontend'; } private static function source_type_for_post_type($post_type) { if ('page' === $post_type || 'post' === $post_type) { return $post_type; } return sanitize_key($post_type); } public static function handle_save() { if (!current_user_can('manage_options')) { wp_die(esc_html__('Permission denied.', 'ait-multilingual-translate')); } check_admin_referer('aitmt_save_scanner_sources'); $options = get_option('aitmt_options', array()); $options = is_array($options) ? $options : array(); $raw = isset($_POST['scanner_sources']) && is_array($_POST['scanner_sources']) ? map_deep(wp_unslash($_POST['scanner_sources']), 'sanitize_text_field') : array(); $options['scanner_sources'] = self::sanitize($raw); update_option('aitmt_options', $options, false); update_option('aitmt_scanner_sources_notice', __('Scanner source selection saved.', 'ait-multilingual-translate'), false); wp_safe_redirect(admin_url('admin.php?page=ait-multilingual-translate-scanner')); exit; } public static function render_ui($options = array()) { $selected = self::selection($options); $preview_rows = self::preview($options, 0); $preview_source_types = array_values(array_unique(array_map(static function ($row) { return sanitize_key((string) ($row['source_type'] ?? '')); }, $preview_rows))); sort($preview_source_types); $preview_objects = array(); foreach ($preview_rows as $row) { $preview_objects[$row['object_key']] = $row['object'] ?: $row['object_key']; } asort($preview_objects); $filters = self::preview_filters($_GET); $preview_rows = self::filter_preview_rows($preview_rows, $filters); $total_rows = count($preview_rows); $total_pages = max(1, (int) ceil($total_rows / 100)); $preview_page = min($total_pages, max(1, isset($_GET['scanner_page']) ? absint($_GET['scanner_page']) : 1)); $preview_rows = array_slice($preview_rows, ($preview_page - 1) * 100, 100); $preview_url = add_query_arg(array_merge(array('page' => 'ait-multilingual-translate-scanner'), $filters), admin_url('admin.php')); $notice = get_option('aitmt_scanner_sources_notice', ''); if ('' !== $notice) { delete_option('aitmt_scanner_sources_notice'); echo '

' . esc_html($notice) . '

'; } $groups = array( 'frontend' => __('Frontend Content', 'ait-multilingual-translate'), 'frontend_shared' => __('Frontend Shared Content', 'ait-multilingual-translate'), 'template' => __('Backend / Templates', 'ait-multilingual-translate'), 'technical' => __('Technical / Metadata', 'ait-multilingual-translate'), 'metadata' => __('Technical / Metadata', 'ait-multilingual-translate'), ); ?>

$heading) : ?>

>


0) : ?>
1) : ?>
__('Frontend', 'ait-multilingual-translate'), 'frontend_shared' => __('Frontend Shared', 'ait-multilingual-translate'), 'template' => __('Template / Backend', 'ait-multilingual-translate'), 'technical' => __('Technical', 'ait-multilingual-translate'), 'metadata' => __('Metadata', 'ait-multilingual-translate'), ); return $labels[$classification] ?? __('Unknown', 'ait-multilingual-translate'); } private static function post_count($post_type) { $counts = wp_count_posts($post_type); return isset($counts->publish) ? absint($counts->publish) : 0; } private static function elementor_page_count() { $query = new WP_Query(array( 'post_type' => 'page', 'post_status' => 'publish', 'meta_key' => '_elementor_data', 'fields' => 'ids', 'posts_per_page' => 1, 'no_found_rows' => false, )); return absint($query->found_posts); } private static function menu_count() { return count(wp_get_nav_menus()); } private static function widget_count() { $sidebars = get_option('sidebars_widgets', array()); $count = 0; foreach ((array) $sidebars as $sidebar => $widgets) { if ('wp_inactive_widgets' !== $sidebar) { $count += count((array) $widgets); } } return $count; } private static function taxonomy_count() { $count = 0; foreach (get_taxonomies(array('public' => true), 'names') as $taxonomy) { $terms = wp_count_terms(array('taxonomy' => $taxonomy, 'hide_empty' => false)); if (!is_wp_error($terms)) { $count += absint($terms); } } return $count; } }