Вот код моего приложения:
from dialogs import *
from synonyms import *
from translation import *
from utils import *
from fileio import *
from constants import *
import tkinter as tk
from tkinter import Menu, Frame, LabelFrame, Label, Entry, Button, StringVar, LEFT, X, BOTH, SUNKEN, W, ttk, filedialog, simpledialog, messagebox, Scrollbar, RIGHT, BOTTOM, Toplevel, Canvas, END, CENTER
import json
import pyperclip
import pandas as pd
import os
from bs4 import BeautifulSoup
import re
from functools import partial
import openpyxl
import collections

class AttributeEditor:
    MAPPINGS_FILE = 'mappings.json'
    EXCLUSIONS_FILE = 'exclusions.json'
    PROTECTED_VALUES_FILE = 'protected_values.json'
    SETTINGS_FILE = 'settings.json'
    CASE_NORM_FILE = 'case_normalization.json'
    REPLACEMENT_RULES_FILE = 'replacement_rules.json'
    PARAM_MERGE_SETTINGS_FILE = 'param_merge_settings.json'
    VALUE_MERGE_SETTINGS_FILE = 'value_merge_settings.json'

    def __init__(self, root):
        self.root = root
        self.root.title("Редактор атрибутов товаров")
        self.root.geometry("1200x700")
        self.original_df = None
        self.param_mapping = {}
        self.original_values = {}
        self.manual_split_values = {}
        self.exclusions = []
        self.mappings = {}
        self.sort_directions = {}
        self.edit_entry = None
        self.edit_undo_stack = []
        self.df_columns = []
        self.mapping_sort_reverse = False
        self._mapping_items_cache = []
        self.settings = {
            "features_col": "",
            "extra_cols": [],
            "manual_split_values": {},
            "source_file_path": "",
            "output_folder_path": ""
        }
        self.case_normalization = {}
        self.manual_split_results = {}
        self.replacement_rules = []
        self.protected_values = []
        self.synonyms_manager = ValueSynonymsManager()
        self.synonyms_manager.add_callback(self.dynamic_update_main_table)
        self.value_translation_rules_manager = ValueTranslationRulesManager()
        self.exclusions_manager = ExclusionsManager()
        self.value_translation_rules_manager.add_callback(self.dynamic_update_main_table)
        self.exclusions_manager.add_callback(self.dynamic_update_main_table)
        self.value_translation_rules = []
        self.translation_available = TRANSLATION_AVAILABLE
        if self.translation_available:
            self.translation_system = BuiltinTranslationSystem()
        else:
            self.translation_system = None
        self.column_history = {}  # Старая история (значения)
        self.column_op_history = {}  # Новая история операций по столбцам
        self.article_to_source_row = {}  # внутренний артикул -> индекс строки исходного файла
        self.load_settings()
        self.setup_ui()
        self.root.protocol("WM_DELETE_WINDOW", self.on_close)
        self.load_column_history()
        self.auto_load_last_profile()
        # --- Автоматическая загрузка переводов параметров при запуске ---
        self.load_param_translations_from_file()

    def setup_ui(self):
        self.create_compact_menu()
        main_frame = Frame(self.root)
        main_frame.pack(fill=BOTH, expand=True, padx=5, pady=2)
        files_frame = LabelFrame(main_frame, text="Файлы и настройки", font=("Arial", 9, "bold"))
        files_frame.pack(fill=X, pady=(0, 2))
        file_row1 = Frame(files_frame)
        file_row1.pack(fill=X, padx=5, pady=2)
        # --- Файл ---
        Label(file_row1, text="Файл:").pack(side=LEFT)
        self.source_file_var = StringVar(value=self.settings.get("source_file_path", ""))
        source_entry = Entry(file_row1, textvariable=self.source_file_var)
        source_entry.pack(side=LEFT, fill=X, expand=True, padx=2)
        source_entry.bind('<FocusOut>', lambda e: self.save_settings())
        Button(file_row1, text="...", width=3, command=self.browse_source_file).pack(side=LEFT, padx=2)
        # --- Столбец ---
        Label(file_row1, text="Столбец:").pack(side=LEFT, padx=(15, 2))
        self.features_col = Entry(file_row1)
        self.features_col.pack(side=LEFT, padx=2)
        self.features_col.insert(0, self.settings.get("features_col", ""))
        # --- Доп.столбцы ---
        Label(file_row1, text="Доп.столбцы:").pack(side=LEFT, padx=(15, 2))
        self.extra_cols_label = Label(file_row1, text="", anchor=W, relief=SUNKEN, bg="white")
        self.extra_cols_label.pack(side=LEFT, fill=X, expand=True, padx=2)
        Button(file_row1, text="...", width=3, command=self.show_extra_cols_dialog).pack(side=LEFT, padx=2)
        file_row2 = Frame(files_frame)
        file_row2.pack(fill=X, padx=5, pady=(0, 2))
        Label(file_row2, text="Папка:", width=6).pack(side=LEFT)
        self.output_folder_var = StringVar(value=self.settings.get("output_folder_path", ""))
        output_entry = Entry(file_row2, textvariable=self.output_folder_var, width=35)
        output_entry.pack(side=LEFT, fill=X, expand=True, padx=2)
        output_entry.bind('<FocusOut>', lambda e: self.save_settings())
        Button(file_row2, text="...", width=3, command=self.browse_output_folder).pack(side=LEFT, padx=2)
        toolbar_frame = LabelFrame(main_frame, text="Действия", font=("Arial", 9, "bold"))
        toolbar_frame.pack(fill=X, pady=(0, 2))
        toolbar = Frame(toolbar_frame)
        toolbar.pack(fill=X, padx=5, pady=3)
        actions_frame = Frame(toolbar)
        actions_frame.pack(side=LEFT)
        self.btn_load = Button(actions_frame, text="Загрузить", command=self.load_file, width=12)
        self.btn_load.pack(side=LEFT, padx=2)
        # Кнопки экспорта рядом с загрузкой
        self.btn_save_xlsx = Button(actions_frame, text="Сохранить в XLSX", command=self.save_results, width=16)
        self.btn_save_xlsx.pack(side=LEFT, padx=2)
        self.btn_save_csv = Button(actions_frame, text="Сохранить в CSV", command=self.export_to_csv, width=16)
        self.btn_save_csv.pack(side=LEFT, padx=2)
        self.notebook = ttk.Notebook(main_frame)
        self.notebook.pack(fill=BOTH, expand=True, pady=2)
        self.main_tab = Frame(self.notebook)
        self.notebook.add(self.main_tab, text="Основная таблица")
        self.setup_main_tab()
        self.values_translation_tab = Frame(self.notebook)
        self.notebook.add(self.values_translation_tab, text="Переводы значений")
        self.setup_values_translation_tab()
        self.parameters_tab = Frame(self.notebook)
        self.notebook.add(self.parameters_tab, text="📝 Параметры")
        self.setup_parameters_tab()
        # Вкладка для проверки и исправления опечаток
        self.typos_tab = Frame(self.notebook)
        self.notebook.add(self.typos_tab, text="Опечатки")
        self.setup_typos_tab()
        self.info_label = Label(main_frame, text="Готов к работе", relief=SUNKEN, anchor=W, font=("Arial", 8), bg="#F0F0F0")
        self.info_label.pack(fill=X, pady=(2, 0))
        # Добавляю кнопки для профилей
        profile_btns = Frame(self.root)
        profile_btns.pack(fill=X, padx=10, pady=2)
        Button(profile_btns, text="Сохранить профиль", command=self.save_profile).pack(side=LEFT, padx=2)
        Button(profile_btns, text="Загрузить профиль", command=self.load_profile).pack(side=LEFT, padx=2)

    def create_compact_menu(self):
        menubar = Menu(self.root)
        self.root.config(menu=menubar)
        file_menu = Menu(menubar, tearoff=0)
        menubar.add_cascade(label="Файл", menu=file_menu)
        file_menu.add_command(label="Загрузить файл", command=self.load_file)
        file_menu.add_separator()
        file_menu.add_command(label="Выход", command=self.on_close)
        self.root.bind('<Control-z>', self.undo_action)
        self.root.bind('<Control-Z>', self.undo_action)
        self.root.bind('<Control-y>', self.redo_action)
        self.root.bind('<Control-Y>', self.redo_action)
        self.root.bind('<Control-c>', self.copy_selected_cell_event)
        self.root.bind('<Control-C>', self.copy_selected_cell_event)
        self.root.bind('<Control-v>', self.paste_to_selected_cell)
        self.root.bind('<Control-V>', self.paste_to_selected_cell)
        self.root.bind('<Control-a>', self.select_all_cells)
        self.root.bind('<Control-A>', self.select_all_cells)
        self.root.bind('<Control-f>', self.focus_search_entry)
        self.root.bind('<Control-F>', self.focus_search_entry)

    def setup_main_tab(self):
        if hasattr(self, 'main_tab'):
            for widget in self.main_tab.winfo_children():
                widget.destroy()
            top_frame = Frame(self.main_tab)
            top_frame.pack(fill=X, padx=5, pady=5)
            # Поиск по артикулу
            Label(top_frame, text="Поиск по артикулу:").pack(side=LEFT)
            self.search_var = StringVar()
            Entry(top_frame, textvariable=self.search_var, width=20).pack(side=LEFT, padx=(0,2))
            Button(top_frame, text="Найти", command=self.apply_article_search, width=7).pack(side=LEFT, padx=(0,2))
            Button(top_frame, text="Сброс", command=self.reset_article_search, width=7).pack(side=LEFT, padx=(0,8))
            # Поиск по параметру
            Label(top_frame, text="Поиск по параметру:").pack(side=LEFT)
            self.param_search_var = StringVar()
            Entry(top_frame, textvariable=self.param_search_var, width=20).pack(side=LEFT, padx=(0,2))
            Button(top_frame, text="Найти", command=self.apply_param_search, width=7).pack(side=LEFT, padx=(0,2))
            Button(top_frame, text="Сброс", command=self.reset_param_search, width=7).pack(side=LEFT, padx=(0,8))
            # Выбор столбца и замена
            Label(top_frame, text="Столбец:").pack(side=LEFT, padx=(0,2))
            self.replace_col_var = StringVar()
            self.replace_col_menu = ttk.Combobox(top_frame, textvariable=self.replace_col_var, state='readonly', width=36)
            self.replace_col_menu.pack(side=LEFT, padx=(0,5))
            Button(top_frame, text="Заменить…", command=self.open_replace_column_dialog, width=10).pack(side=LEFT, padx=(0,2))
            # Кнопка истории столбца
            Button(top_frame, text="История столбца", command=self.open_column_history_dialog, width=16).pack(side=LEFT, padx=(0,8))
            # Кнопка настройки столбцов
            Button(top_frame, text="Настроить столбцы", command=self.open_column_visibility_dialog, width=16).pack(side=LEFT, padx=(0,8))
            # Таблица с горизонтальным и вертикальным скроллами
            table_frame = Frame(self.main_tab)
            table_frame.pack(fill=BOTH, expand=True, padx=5, pady=2)
            yscroll = Scrollbar(table_frame, orient='vertical')
            xscroll = Scrollbar(table_frame, orient='horizontal')
            self.tree = ttk.Treeview(table_frame, show='headings', yscrollcommand=yscroll.set, xscrollcommand=xscroll.set)
            yscroll.config(command=self.tree.yview)
            xscroll.config(command=self.tree.xview)
            yscroll.pack(side=RIGHT, fill=Y)
            xscroll.pack(side=BOTTOM, fill=X)
            self.tree.pack(side=LEFT, fill=BOTH, expand=True)
            self.tree.bind('<Control-C>', self.copy_selected_cell_event)
            self.tree.bind('<Double-1>', self.on_main_tree_double_click)
            self.tree.bind('<Button-3>', self.show_cell_menu)  # ПКМ
            self.tree.bind('<Control-c>', self.copy_cell_under_mouse_event)
            self.tree.bind('<Control-C>', self.copy_cell_under_mouse_event)
            self.cell_menu = tk.Menu(self.tree, tearoff=0)
            self.cell_menu.add_command(label="Копировать значение ячейки", command=self.copy_selected_cell)

    def setup_values_translation_tab(self):
        if hasattr(self, 'values_translation_tab'):
            for widget in self.values_translation_tab.winfo_children():
                widget.destroy()
            # --- КОМПАКТНЫЕ КНОПКИ НАВЕРХУ ---
            top_btns = Frame(self.values_translation_tab)
            top_btns.pack(fill=X, pady=5)
            Button(top_btns, text="Добавить", command=self.add_value_translation_rule_dialog, width=10).pack(side=LEFT, padx=2)
            Button(top_btns, text="Активировать все", command=self.activate_all_value_translations, width=14).pack(side=LEFT, padx=2)
            Button(top_btns, text="Деактивировать все", command=self.deactivate_all_value_translations, width=16).pack(side=LEFT, padx=2)
            # --- Canvas + Scrollbar для вертикального скролла ---
            canvas = Canvas(self.values_translation_tab)
            canvas.pack(side=LEFT, fill=BOTH, expand=True)
            vscroll = Scrollbar(self.values_translation_tab, orient='vertical', command=canvas.yview)
            vscroll.pack(side=RIGHT, fill=Y)
            canvas.configure(yscrollcommand=vscroll.set)
            inner_frame = Frame(canvas)
            canvas.create_window((0, 0), window=inner_frame, anchor='nw')
            def on_configure(event):
                canvas.configure(scrollregion=canvas.bbox('all'))
            inner_frame.bind('<Configure>', on_configure)
            # --- Новый блок: чекбоксы для каждой замены с кнопками ---
            import json
            import tkinter as tk
            from tkinter import simpledialog, messagebox
            with open('value_translation_rules.json', encoding='utf-8') as f:
                rules = json.load(f)
            self.value_translation_vars = []
            cb_frame = Frame(inner_frame)
            cb_frame.pack(fill=X, pady=5)
            Label(cb_frame, text="Для редактирования или удаления используйте кнопки рядом с правилом.", fg="blue").pack(anchor='w', pady=(0,5))
            for i, rule in enumerate(rules):
                row = Frame(cb_frame)
                row.pack(fill=X, pady=1)
                var = tk.BooleanVar(value=rule.get('active', True))
                cb = tk.Checkbutton(row, text=f"{rule['original']} → {rule['translation']} ({rule.get('description','')})", variable=var,
                                   command=partial(self.toggle_value_translation_rule, i, var))
                cb.pack(side=LEFT)
                # Кнопка редактирования
                def make_edit(idx):
                    return lambda: self.edit_value_translation_rule_by_index(idx)
                btn_edit = Button(row, text="✎", command=make_edit(i), width=2)
                btn_edit.pack(side=LEFT, padx=2)
                # Кнопка удаления
                def make_delete(idx):
                    return lambda: self.delete_value_translation_rule_by_index(idx)
                btn_del = Button(row, text="✖", command=make_delete(i), width=2, fg='red')
                btn_del.pack(side=LEFT, padx=2)
                self.value_translation_vars.append(var)
            self.value_translation_rules = rules

    def delete_value_translation_rule_by_index(self, idx):
        import json
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        if 0 <= idx < len(rules):
            del rules[idx]
            with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
                json.dump(rules, f, ensure_ascii=False, indent=2)
            self.setup_values_translation_tab()
            self.fill_main_table_from_characteristics()

    def add_value_translation_rule_dialog(self):
        import json
        from tkinter import simpledialog
        orig = simpledialog.askstring("Новое правило", "Исходное значение (единица):")
        if not orig:
            return
        trans = simpledialog.askstring("Новое правило", "На что заменить:")
        if trans is None:
            return
        desc = simpledialog.askstring("Новое правило", "Описание (необязательно):")
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        rules.append({'original': orig, 'translation': trans, 'description': desc or '', 'active': True})
        with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
            json.dump(rules, f, ensure_ascii=False, indent=2)
        self.setup_values_translation_tab()
        self.fill_main_table_from_characteristics()

    def edit_value_translation_rule_dialog(self):
        import json
        from tkinter import simpledialog, messagebox
        # Определяем выбранный индекс (по первому отмеченному чекбоксу)
        idx = None
        for i, var in enumerate(self.value_translation_vars):
            if var.get():
                idx = i
                break
        if idx is None:
            messagebox.showinfo("Редактировать правило", "Сначала выберите правило для редактирования.")
            return
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        rule = rules[idx]
        orig = simpledialog.askstring("Редактировать правило", "Исходное значение (единица):", initialvalue=rule['original'])
        if not orig:
            return
        trans = simpledialog.askstring("Редактировать правило", "На что заменить:", initialvalue=rule['translation'])
        if trans is None:
            return
        desc = simpledialog.askstring("Редактировать правило", "Описание (необязательно):", initialvalue=rule.get('description',''))
        rules[idx] = {'original': orig, 'translation': trans, 'description': desc or '', 'active': rule.get('active', True)}
        with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
            json.dump(rules, f, ensure_ascii=False, indent=2)
        self.setup_values_translation_tab()
        self.fill_main_table_from_characteristics()

    def toggle_value_translation_rule(self, idx, var):
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        rules[idx]['active'] = var.get()
        with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
            json.dump(rules, f, ensure_ascii=False, indent=2)
        self.value_translation_rules = rules
        # Можно добавить автообновление основной таблицы при изменении
        self.fill_main_table_from_characteristics()

    def activate_all_value_translations(self):
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        for rule in rules:
            rule['active'] = True
        with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
            json.dump(rules, f, ensure_ascii=False, indent=2)
        self.setup_values_translation_tab()
        self.fill_main_table_from_characteristics()

    def deactivate_all_value_translations(self):
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        for rule in rules:
            rule['active'] = False
        with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
            json.dump(rules, f, ensure_ascii=False, indent=2)
        self.setup_values_translation_tab()
        self.fill_main_table_from_characteristics()

    def load_settings(self):
        self.settings = load_settings(self.SETTINGS_FILE)

    def save_settings(self):
        save_settings(self.settings, self.SETTINGS_FILE)

    def load_file(self):
        self.save_column_history()  # Сохраняем историю перед загрузкой нового файла
        file_path = self.source_file_var.get().strip()
        if not file_path:
            self.info_label.config(text='Файл не выбран')
            return
        try:
            if file_path.lower().endswith('.xlsx'):
                import pandas as pd
                self.original_df = pd.read_excel(file_path)
            else:
                import pandas as pd
                self.original_df = pd.read_csv(file_path, encoding='utf-8-sig')
            self._pristine_df = self.original_df.copy(deep=True)  # Сохраняем чистую копию исходных данных (глубокая копия)
            print('PRISTINE INIT:', list(self._pristine_df[self._pristine_df.columns[0]])[:5])
            self.info_label.config(text=f'Загружено: {file_path}')
        except Exception as e:
            self.info_label.config(text=f"Ошибка загрузки файла: {e}")
        # После загрузки файла:
        self.load_column_history()  # Подгружаем историю после загрузки файла
        self.fill_main_table_from_characteristics()

    def parse_html_table_wide(self, html):
        soup = BeautifulSoup(html, 'html.parser')
        trs = soup.find_all('tr')
        if not trs:
            return [], []
        # Получаем артикулы из первой строки
        first_row = trs[0]
        ths = first_row.find_all(['td', 'th'])
        articles = [td.get_text(strip=True) for td in ths[1:]]  # Все ячейки после первой
        if not articles:
            # Если нет ячеек после первой, проверяем другие строки
            for tr2 in trs[1:]:
                tds2 = tr2.find_all(['td', 'th'])
                if len(tds2) > 1:
                    articles = [td.get_text(strip=True) for td in tds2[1:]]
                    break
        n_articles = len(articles)
        # Собираем параметры и значения
        params = []
        values_by_article = {art: {} for art in articles}
        for tr in trs[1:]:
            tds = tr.find_all(['td', 'th'])
            # Пропускаем заголовки разделов (colspan >= 3 или пустой первый столбец)
            if len(tds) == 1 and (tds[0].has_attr('colspan') and int(tds[0]['colspan']) >= 3):
                continue
            if len(tds) > 0 and (tds[0].get_text(strip=True) == '' or (tds[0].has_attr('colspan') and int(tds[0]['colspan']) >= 3)):
                continue
            param = tds[0].get_text(strip=True) if tds else ''
            if not param:
                continue
            if param not in params:
                params.append(param)
            # Значения для артикулов
            # Корректное определение td_idx для строк с/без параметра
            if tds and tds[0].get_text(strip=True):
                td_idx = 1
            else:
                td_idx = 0
            values = []
            art_idx = 0
            while art_idx < n_articles and td_idx < len(tds):
                td = tds[td_idx]
                colspan = int(td.get('colspan', 1))
                value = td.get_text(strip=True)
                for _ in range(colspan):
                    if art_idx < n_articles:
                        values.append(value)
                        art_idx += 1
                td_idx += 1
            while len(values) < n_articles:
                values.append('')
            for art, val in zip(articles, values):
                values_by_article[art][param] = val
        return articles, params, values_by_article

    def fill_main_table_from_characteristics(self):
        self.save_column_history()  # Сохраняем историю перед пересозданием таблицы
        try:
            if not hasattr(self, 'tree') or self.original_df is None:
                return
            self.tree.delete(*self.tree.get_children())
            char_col = None
            for col in self.original_df.columns:
                if 'характеристик' in col.lower():
                    char_col = col
                    break
            if char_col is None:
                self.info_label.config(text="Столбец 'Характеристики' не найден")
                return
            all_articles = []
            all_params = []
            all_values = {}
            section_headers = set()
            from bs4 import BeautifulSoup
            # --- get_val_lines удалён, используем self.parse_td_lines ---
            for idx, row in self.original_df.iterrows():
                html = str(row[char_col])
                soup = BeautifulSoup(html, 'html.parser')
                trs = soup.find_all('tr')
                if not trs:
                    continue
                first_row = trs[0]
                ths = first_row.find_all(['td', 'th'])
                articles = [td.get_text(strip=True) for td in ths[1:]]
                if len(articles) < 1:
                    continue
                art1 = articles[0]
                art2 = articles[1] if len(articles) > 1 else None
                for art in articles:
                    if art not in all_articles:
                        all_articles.append(art)
                    self.article_to_source_row[art] = idx
                values_by_article = {art: {} for art in articles}
                trs_list = list(trs[1:])
                i = 0
                while i < len(trs_list):
                    tr = trs_list[i]
                    tds = tr.find_all(['td', 'th'])
                    if len(tds) == 1 and int(tds[0].get('colspan', 1)) == len(articles)+1:
                        i += 1
                        continue
                    if len(tds) == 2 and tds[0].has_attr('rowspan'):
                        param = tds[0].get_text(strip=True)
                        rowspan = int(tds[0]['rowspan'])
                        vals = []
                        for j in range(rowspan):
                            current_i = i + j
                            if current_i >= len(trs_list):
                                break
                            current_tr = trs_list[current_i]
                            current_tds = current_tr.find_all(['td', 'th'])
                            if not current_tds:
                                continue
                            if j == 0:
                                if len(current_tds) > 1:
                                    val_lines = self.parse_td_lines(current_tds[1])
                                else:
                                    val_lines = self.parse_td_lines(current_tds[0])
                            else:
                                if len(current_tds) >= 1:
                                    val_lines = self.parse_td_lines(current_tds[0])
                                else:
                                    val_lines = []
                            if val_lines:
                                vals.extend(val_lines)
                        if vals:
                            val_joined = ', '.join(vals)
                            for art in articles:
                                if param not in values_by_article[art] or not values_by_article[art][param]:
                                    values_by_article[art][param] = val_joined
                            if param not in all_params:
                                all_params.append(param)
                        i += rowspan
                        continue
                    if len(tds) == 2:
                        param = tds[0].get_text(strip=True)
                        val = tds[1].get_text("\n", strip=True)
                        val_lines = [v.strip() for v in val.split("\n") if v.strip()]
                        val = ', '.join(val_lines)
                        colspan = int(tds[1].get('colspan', 1))
                        if colspan >= len(articles):
                            for art in articles:
                                if param not in values_by_article[art] or not values_by_article[art][param]:
                                    values_by_article[art][param] = val
                            if param not in all_params:
                                all_params.append(param)
                            i += 1
                            continue
                        elif colspan > 1:
                            for art_idx in range(colspan):
                                if art_idx < len(articles):
                                    art = articles[art_idx]
                                    if param not in values_by_article[art] or not values_by_article[art][param]:
                                        values_by_article[art][param] = val
                            if param not in all_params:
                                all_params.append(param)
                            i += 1
                            continue
                        else:
                            if art1:
                                if param not in values_by_article[art1] or not values_by_article[art1][param]:
                                    values_by_article[art1][param] = val
                            if param not in all_params:
                                all_params.append(param)
                            i += 1
                            continue
                    if len(tds) > 2:
                        param = tds[0].get_text(strip=True)
                        art_idx = 0
                        for val_td in tds[1:]:
                            val_lines = self.parse_td_lines(val_td)
                            val = ', '.join(val_lines)
                            colspan = int(val_td.get('colspan', 1))
                            for k in range(colspan):
                                if art_idx < len(articles):
                                    art = articles[art_idx]
                                    if param not in values_by_article[art] or not values_by_article[art][param]:
                                        values_by_article[art][param] = val
                                    art_idx += 1
                        if param not in all_params:
                            all_params.append(param)
                        i += 1
                        continue
                    i += 1
                for art in articles:
                    if art not in all_values:
                        all_values[art] = {}
                    for param, val in values_by_article[art].items():
                        all_values[art][param] = [val] if val else ['']
            all_params = [p for p in all_params if p not in section_headers and p.strip()]
            data = []
            import re, json, os
            value_translation_rules = []
            if os.path.exists('value_translation_rules.json'):
                with open('value_translation_rules.json', encoding='utf-8') as f:
                    value_translation_rules = [r for r in json.load(f) if r.get('active', True)]
            def apply_value_translations(val):
                v = str(val)
                for rule in self.value_translation_rules:
                    if rule.get('active', True):
                        orig = rule['original']
                        repl = rule['translation']
                        v = re.sub(rf'(\d[\d., ]*|\b)({re.escape(orig)})\b', lambda m: m.group(1) + repl, v, flags=re.IGNORECASE)
                return v
            for art in all_articles:
                row = {'Артикул': art}
                for p in all_params:
                    vals = all_values.get(art, {}).get(p, [])
                    unique_vals = remove_duplicates_normalized(vals)
                    joined = ', '.join([apply_value_translations(self.translation_system.translate_text(clean_value_enhanced(v), is_parameter=False)) for v in unique_vals])
                    row[p] = joined
                data.append(row)
            df = pd.DataFrame(data)
            non_empty_cols = ['Артикул'] + [col for col in all_params if df[col].astype(str).str.strip().replace('nan','').any()]
            df = df[non_empty_cols]
            columns = list(df.columns)
            self.last_table_columns = columns
            self.visible_columns = columns.copy()
            self.tree['columns'] = columns
            self.tree['show'] = 'headings'
            for col in columns:
                self.tree.heading(col, text=col)
                max_len = max([len(str(x)) for x in [col] + list(df[col])] + [10])
                width = min(max_len * 10, 800)
                self.tree.column(col, width=width, minwidth=80, stretch=False)
            for i, row in df.iterrows():
                values = [row[col] for col in columns]
                self.tree.insert('', 'end', values=values)
            self.current_df = df
            param_merge_rules = []
            if os.path.exists('param_merge_rules.json'):
                with open('param_merge_rules.json', encoding='utf-8') as f:
                    param_merge_rules = json.load(f)
            for rule in param_merge_rules:
                params = rule.get('params') or rule.get('parameters')
                target = rule.get('target')
                if params and target:
                    self.perform_param_merge(params, target)
            self.merge_duplicate_columns_by_normalized_name()
            self.setup_parameters_tab()
            self.init_param_translations_from_tree()
            self.refresh_main_table_headers_with_translations()
            if hasattr(self, 'setup_typos_tab'):
                self.setup_typos_tab()
            # --- Сохраняем "чистую" копию всей таблицы после парсинга ---
            self._pristine_table_df = self.current_df.copy(deep=True)
            # --- Делаем все правила активными ---
            for col, ops in self.column_op_history.items():
                self.column_history_active[col] = [True] * len(ops)
            # --- Автоматически применяем все активные правила ---
            for col in self.column_op_history:
                self.recalc_column_from_history(col)
            # После формирования self.current_df:
            if hasattr(self, 'current_df') and self.current_df is not None:
                self.visible_columns = list(self.current_df.columns)
                self.refresh_main_table_from_df()
                # --- Автоматически применить переводы из файла ---
                self.load_param_translations_from_file()
                self.refresh_main_table_headers_with_translations()
            # --- Исправление: только один столбец 'Артикул' и правильные значения ---
            if 'Артикул' in self.current_df.columns:
                orig_articles = list(self.original_df['Артикул']) if 'Артикул' in self.original_df.columns else []
                for i, idx in enumerate(self.current_df.index):
                    if i < len(orig_articles):
                        self.current_df.at[idx, 'Артикул'] = orig_articles[i]
            cols = [c for c in self.current_df.columns if c == 'Артикул']
            if len(cols) > 1:
                first_idx = self.current_df.columns.get_loc('Артикул')
                self.current_df = self.current_df.loc[:, ~self.current_df.columns.duplicated()]
        except Exception:
            pass

    def merge_duplicate_columns_by_normalized_name(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df
        norm_to_cols = {}
        for col in df.columns:
            norm = normalize_param_name(col)
            norm_to_cols.setdefault(norm, []).append(col)
        for norm, cols in norm_to_cols.items():
            if len(cols) > 1:
                merged = df[cols].astype(str).apply(lambda row: '; '.join(sorted(set([v for v in row if v and v != 'nan']))), axis=1)
                df[norm] = merged
                for c in cols:
                    if c != norm and c in df.columns:
                        df.drop(columns=[c], inplace=True)
        self.current_df = df
        self.refresh_main_table_from_df()
        self.refresh_main_table_headers_with_translations()

    def save_results(self):
        if not hasattr(self, 'original_df') or self.original_df is None:
            self.info_label.config(text="Нет данных для сохранения")
            return
        html_col = self.features_col.get().strip()
        if html_col not in self.original_df.columns:
            self.info_label.config(text="Ошибка: столбец характеристик не найден")
            return
        extra_col_names = self.settings.get("extra_cols", [])
        available_extra_cols = [col for col in extra_col_names if col in self.original_df.columns]
        # --- Собираем итоговый DataFrame для выгрузки ---
        result_rows = []
        for i, row in self.current_df.iterrows():
            result_row = {col: row[col] for col in self.current_df.columns if col != 'Артикул'}
            art = row['Артикул'] if 'Артикул' in row else None
            if art and hasattr(self, 'article_to_source_row') and art in self.article_to_source_row:
                src_idx = self.article_to_source_row[art]
                src_row = self.original_df.iloc[src_idx]
                result_row['Артикул'] = src_row['Артикул'] if 'Артикул' in src_row else ''
                for col in available_extra_cols:
                    result_row[col] = src_row[col] if col in src_row else ''
            else:
                result_row['Артикул'] = art if art else ''
                for col in available_extra_cols:
                    result_row[col] = ''
            result_rows.append(result_row)
        if not result_rows:
            self.info_label.config(text="Нет данных для выгрузки")
            return
        df_out = pd.DataFrame(result_rows)
        # --- Используем переводы параметров для выгрузки ---
        translations = getattr(self, 'param_translations', None)
        if translations:
            df_out.rename(columns={col: translations.get(normalize_param_name(col), col) for col in df_out.columns}, inplace=True)
        out_path = os.path.join(os.path.dirname(self.settings.get('source_file_path', '')), 'woocommerce_ready.xlsx')
        try:
            df_out.to_excel(out_path, index=False)
            self.info_label.config(text=f"Файл сохранён: {out_path}")
        except Exception as e:
            self.info_label.config(text=f"Ошибка при сохранении: {e}")

    def open_translation_dialog(self):
        BuiltinTranslationDialog(self.root, self.translation_system)

    def open_replacement_rules_editor(self):
        ReplacementRulesEditorDialog(self.root, self.replacement_rules, self.save_settings)

    def open_case_normalization_dialog(self):
        CaseNormalizationDialog(self.root, self.case_normalization, self.save_settings)

    def open_value_synonyms_dialog(self):
        ValueSynonymsDialog(self.root, self.synonyms_manager)

    def open_protected_values_dialog(self):
        if 'ProtectedValuesDialog' in globals():
            ProtectedValuesDialog(self.root)
        else:
            from tkinter import messagebox
            messagebox.showinfo("Защищённые значения", "Здесь будет диалог для защищённых значений.")

    def open_exclusions_dialog(self):
        ExclusionsDialog(self.root, self.exclusions_manager)

    def show_extra_cols_dialog(self):
        if not hasattr(self, 'df_columns') or not self.df_columns:
            messagebox.showwarning("Ошибка", "Сначала загрузите файл")
            return
        current_selection = set(self.settings.get('extra_cols', []))
        dialog = Toplevel(self.root)
        dialog.title("Выбор дополнительных столбцов")
        dialog.geometry("400x500")
        vars = {}
        frame = Frame(dialog)
        frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
        for col in self.df_columns:
            var = tk.BooleanVar(value=col in current_selection)
            chk = Checkbutton(frame, text=f"{self.df_columns.index(col)+1}. {col}", variable=var)
            chk.pack(anchor=W)
            vars[col] = var
        def apply():
            selected = [col for col, v in vars.items() if v.get()]
            self.settings['extra_cols'] = selected
            self.update_extra_cols_label()
            self.save_settings()
            dialog.destroy()
        Button(dialog, text="Применить", command=apply).pack(pady=10)

    def browse_source_file(self):
        filename = filedialog.askopenfilename(title="Выберите Excel файл", filetypes=[("Excel files", "*.xlsx *.xls"), ("All files", "*.*")])
        if filename:
            self.source_file_var.set(filename)
            self.save_settings()

    def browse_output_folder(self):
        folder = filedialog.askdirectory(title="Выберите папку для сохранения")
        if folder:
            self.output_folder_var.set(folder)
            self.save_settings()

    def refresh_mapping_table(self):
        if hasattr(self, 'tree') and hasattr(self, 'mappings'):
            self.tree.delete(*self.tree.get_children())
            for i, (orig, repl) in enumerate(self.mappings.items(), 1):
                self.tree.insert('', 'end', values=(i, orig, '', repl, '', ''))

    def dynamic_update_main_table(self):
        self.refresh_mapping_table()
        self.info_label.config(text="Таблица обновлена (callback)")

    def on_close(self):
        self.save_settings()
        # --- Автосохранение переводов параметров ---
        if hasattr(self, 'save_param_translations') and hasattr(self, 'param_translations') and hasattr(self, 'current_df'):
            try:
                self.save_param_translations()
            except Exception as e:
                print('Ошибка автосохранения переводов:', e)
        self.root.destroy()

    def copy_selected_value(self):
        selected = self.tree.selection()
        if selected:
            value = self.tree.item(selected[0])['values']
            pyperclip.copy(str(value))
            self.info_label.config(text="Значение скопировано в буфер обмена")
        else:
            self.info_label.config(text="Нет выбранной строки для копирования")

    def show_cell_menu(self, event):
        region = self.tree.identify('region', event.x, event.y)
        if region == 'cell':
            rowid = self.tree.identify_row(event.y)
            col = self.tree.identify_column(event.x)
            self.tree.selection_set(rowid)
            self.tree.focus(rowid)
            self._cell_menu_row = rowid
            self._cell_menu_col = col
            self.cell_menu.post(event.x_root, event.y_root)

    def copy_selected_cell(self):
        try:
            rowid = getattr(self, '_cell_menu_row', None)
            col = getattr(self, '_cell_menu_col', None)
            if rowid and col:
                col_idx = int(col.replace('#', '')) - 1
                values = self.tree.item(rowid)['values']
                if 0 <= col_idx < len(values):
                    pyperclip.copy(str(values[col_idx]))
        except Exception:
            pass

    def copy_cell_under_mouse_event(self, event=None):
        # Копировать значение ячейки под мышью или выделенной
        try:
            x = self.tree.winfo_pointerx() - self.tree.winfo_rootx()
            y = self.tree.winfo_pointery() - self.tree.winfo_rooty()
            rowid = self.tree.identify_row(y)
            col = self.tree.identify_column(x)
            if rowid and col:
                col_idx = int(col.replace('#', '')) - 1
                values = self.tree.item(rowid)['values']
                if 0 <= col_idx < len(values):
                    pyperclip.copy(str(values[col_idx]))
                    self.info_label.config(text="Значение ячейки скопировано")
                    return
            # fallback: если не удалось, копируем первую ячейку выделенной строки
            selected = self.tree.selection()
            if selected:
                values = self.tree.item(selected[0])['values']
                if values:
                    pyperclip.copy(str(values[0]))
                    self.info_label.config(text="Значение ячейки скопировано")
        except Exception:
            pass

    def copy_selected_cell_event(self, event=None):
        try:
            selected = self.tree.selection()
            if not selected:
                return
            rowid = selected[0]
            focus_col = self.tree.focus()
            # Получаем выделенную ячейку (по фокусу или первой колонке)
            col = self.tree.identify_column(event.x) if event and hasattr(event, 'x') else '#1'
            col_idx = int(col.replace('#', '')) - 1
            values = self.tree.item(rowid)['values']
            if 0 <= col_idx < len(values):
                pyperclip.copy(str(values[col_idx]))
        except Exception:
            pass

    def open_replace_column_dialog(self):
        col = self.replace_col_var.get()
        if not col or not hasattr(self, 'current_df') or self.current_df is None:
            return
        dialog = Toplevel(self.root)
        dialog.title(f"Замена в столбце: {col}")
        dialog.geometry("400x200")
        Label(dialog, text=f"Столбец: {col}").pack(pady=5)
        find_var = StringVar()
        replace_var = StringVar()
        regex_var = IntVar()
        frame = Frame(dialog)
        frame.pack(pady=10)
        Label(frame, text="Что заменить:").grid(row=0, column=0, sticky=W)
        Entry(frame, textvariable=find_var, width=30).grid(row=0, column=1)
        Label(frame, text="На что заменить:").grid(row=1, column=0, sticky=W)
        Entry(frame, textvariable=replace_var, width=30).grid(row=1, column=1)
        Checkbutton(dialog, text="Использовать регулярное выражение", variable=regex_var).pack()
        Button(dialog, text="Заменить", command=lambda: self.apply_column_replace(dialog, col, find_var.get(), replace_var.get(), regex_var.get())).pack(pady=10)

    def apply_column_replace(self, dialog, col, find, replace, use_regex):
        import re
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df.copy()
        orig_col = self.get_original_column_name(col)
        def norm(s): return ''.join(s.lower().split())
        real_col = None
        if orig_col:
            for c in df.columns:
                if c == orig_col:
                    real_col = c
                    break
            if not real_col:
                for c in df.columns:
                    if c.lower() == orig_col.lower():
                        real_col = c
                        break
            if not real_col:
                ncol = norm(orig_col)
                for c in df.columns:
                    if norm(c) == ncol:
                        real_col = c
                        break
        if not real_col:
            for c in df.columns:
                if c == col:
                    real_col = c
                    break
        if not real_col:
            for c in df.columns:
                if c.lower() == col.lower():
                    real_col = c
                    break
        if not real_col:
            ncol = norm(col)
            for c in df.columns:
                if norm(c) == ncol:
                    real_col = c
                    break
        print(f"DEBUG: apply_column_replace real_col={real_col}, col={col}, orig_col={orig_col}")
        if not real_col or real_col not in df.columns:
            from tkinter import messagebox
            real_cols = list(df.columns)
            msg = f'Столбец "{col}" не найден в таблице!\n\nТекущие столбцы:\n' + '\n'.join(real_cols)
            print('DEBUG: Все столбцы DataFrame:', real_cols)
            messagebox.showerror('Ошибка', msg)
            dialog.destroy()
            return
        # Применяем замену только по точному совпадению (без глобального re.sub)
        if use_regex:
            df[real_col] = df[real_col].astype(str).apply(lambda x: re.sub(find, replace, x, flags=re.IGNORECASE))
        else:
            df[real_col] = df[real_col].astype(str).apply(lambda x: x.replace(find, replace))
        # --- Сохраняем историю изменений ---
        if real_col not in self.column_op_history:
            self.column_op_history[real_col] = []
        op_descr = f"Замена: '{find}' → '{replace}' (regex: {bool(use_regex)})"
        print(f"DEBUG: apply_column_replace op_descr={op_descr}")
        self.column_op_history[real_col].append(op_descr)
        if real_col not in self.column_history:
            self.column_history[real_col] = []
        self.column_history[real_col].append(list(df[real_col]))
        if not hasattr(self, 'column_history_active'):
            self.column_history_active = {}
        if real_col not in self.column_history_active:
            self.column_history_active[real_col] = []
        self.column_history_active[real_col].append(True)
        self.save_column_history()
        self.current_df = df
        self.refresh_main_table_from_df()
        self.save_undo_state()
        # --- АВТОСОХРАНЕНИЕ DataFrame после замены ---
        if hasattr(self, 'source_file_var') and self.source_file_var.get():
            file_path = self.source_file_var.get()
            try:
                if file_path.lower().endswith('.xlsx'):
                    self.current_df.to_excel(file_path, index=False)
                else:
                    self.current_df.to_csv(file_path, index=False, encoding='utf-8-sig')
                self.info_label.config(text=f'Изменения сохранены в {file_path}')
            except Exception as e:
                print('Ошибка автосохранения:', e)
        dialog.destroy()

    def get_original_column_name(self, selected_col):
        df = self.current_df
        translations = getattr(self, 'param_translations', None)
        # 1. Поиск по переводу → оригинал
        if translations:
            for orig, tr in translations.items():
                if tr == selected_col:
                    return orig
        # 2. Регистр-независимо
        if translations:
            for orig, tr in translations.items():
                if tr.lower() == selected_col.lower():
                    return orig
        # 3. Нормализованно
        def norm(s): return ''.join(s.lower().split())
        ncol = norm(selected_col)
        if translations:
            for orig, tr in translations.items():
                if norm(tr) == ncol:
                    return orig
        # 4. По всем столбцам DataFrame
        for c in df.columns:
            if c == selected_col or c.lower() == selected_col.lower() or norm(c) == ncol:
                return c
        return None

    def apply_article_search(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        search_text = self.search_var.get().strip().lower()
        if not search_text:
            self.reset_article_search()
            return
        df = self.current_df
        filtered = df[df['Артикул'].str.lower().str.contains(search_text, na=False)]
        self.tree.delete(*self.tree.get_children())
        columns = list(df.columns)
        for i, row in filtered.iterrows():
            values = [row[col] for col in columns]
            self.tree.insert('', 'end', values=values)

    def reset_article_search(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df
        self.tree.delete(*self.tree.get_children())
        columns = list(df.columns)
        for i, row in df.iterrows():
            values = [row[col] for col in columns]
            self.tree.insert('', 'end', values=values)

    def apply_param_search(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        search_text = self.param_search_var.get().strip().lower()
        if not search_text:
            self.reset_param_search()
            return
        df = self.current_df
        # Фильтруем столбцы по подстроке
        filtered_cols = [col for col in df.columns if search_text in col.lower()]
        if not filtered_cols:
            self.tree.delete(*self.tree.get_children())
            return
        self.tree.delete(*self.tree.get_children())
        self.tree['columns'] = filtered_cols
        self.tree['show'] = 'headings'
        for col in filtered_cols:
            self.tree.heading(col, text=col)
            max_len = max([len(str(x)) for x in [col] + list(df[col])] + [10])
            width = min(max_len * 10, 800)
            self.tree.column(col, width=width, minwidth=80, stretch=False)
        for i, row in df.iterrows():
            values = [row[col] for col in filtered_cols]
            self.tree.insert('', 'end', values=values)

    def reset_param_search(self):
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df
        self.tree.delete(*self.tree.get_children())
        columns = list(df.columns)
        self.tree['columns'] = columns
        self.tree['show'] = 'headings'
        for col in columns:
            self.tree.heading(col, text=col)
            max_len = max([len(str(x)) for x in [col] + list(df[col])] + [10])
            width = min(max_len * 10, 800)
            self.tree.column(col, width=width, minwidth=80, stretch=False)
        for i, row in df.iterrows():
            values = [row[col] for col in columns]
            self.tree.insert('', 'end', values=values)
        self.refresh_main_table_headers_with_translations()

    def open_column_visibility_dialog(self):
        dialog = Toplevel(self.root)
        dialog.title("Настройка видимости столбцов")
        dialog.geometry("400x500")
        # --- Только реально отображаемые столбцы, без дублей ---
        columns = list(self.current_df.columns)
        visible_columns = set(self.visible_columns) if hasattr(self, 'visible_columns') else set(columns)
        if os.path.exists('visible_columns.json'):
            try:
                with open('visible_columns.json', 'r', encoding='utf-8') as f:
                    saved = json.load(f)
                visible_columns = set(saved)
            except Exception:
                pass
        vars = {}
        btns = Frame(dialog)
        btns.pack(fill=X, pady=10)
        def select_all():
            for v in vars.values():
                v.set(1)
        def deselect_all():
            for v in vars.values():
                v.set(0)
        Button(btns, text="Выделить все", command=select_all).pack(side=LEFT, padx=5)
        Button(btns, text="Снять все", command=deselect_all).pack(side=LEFT, padx=5)
        def apply():
            self.visible_columns = [col for col, v in vars.items() if v.get()]
            self.tree['displaycolumns'] = self.visible_columns
            # Сохраняем настройки видимости
            try:
                with open('visible_columns.json', 'w', encoding='utf-8') as f:
                    json.dump(self.visible_columns, f, ensure_ascii=False, indent=2)
            except Exception:
                pass
            dialog.destroy()
        Button(btns, text="Применить", command=apply).pack(side=LEFT, padx=5)
        canvas = Canvas(dialog)
        canvas.pack(side=LEFT, fill=BOTH, expand=True)
        scrollbar = Scrollbar(dialog, orient=VERTICAL, command=canvas.yview)
        scrollbar.pack(side=RIGHT, fill=Y)
        canvas.configure(yscrollcommand=scrollbar.set)
        inner_frame = Frame(canvas)
        canvas.create_window((0, 0), window=inner_frame, anchor='nw')
        def on_configure(event):
            canvas.configure(scrollregion=canvas.bbox('all'))
        inner_frame.bind('<Configure>', on_configure)
        for i, col in enumerate(columns):
            var = IntVar(value=1 if col in visible_columns else 0)
            vars[col] = var
            Checkbutton(inner_frame, text=col, variable=var).pack(anchor=W)

    def open_column_history_dialog(self, real_col=None):
        # --- Сохраняем текущее отображаемое значение столбца ---
        current_display_col = self.replace_col_var.get() if hasattr(self, 'replace_col_var') else None
        if real_col is None:
            col = self.replace_col_var.get()
            orig_col = self.get_original_column_name(col)
            df = self.current_df
            def norm(s): return ''.join(s.lower().split())
            real_col = None
            if orig_col:
                for c in df.columns:
                    if c == orig_col:
                        real_col = c
                        break
                if not real_col:
                    for c in df.columns:
                        if c.lower() == orig_col.lower():
                            real_col = c
                            break
                if not real_col:
                    ncol = norm(orig_col)
                    for c in df.columns:
                        if norm(c) == ncol:
                            real_col = c
                            break
            if not real_col:
                for c in df.columns:
                    if c == col:
                        real_col = c
                        break
            if not real_col:
                for c in df.columns:
                    if c.lower() == col.lower():
                        real_col = c
                        break
            if not real_col:
                ncol = norm(col)
                for c in df.columns:
                    if norm(c) == ncol:
                        real_col = c
                        break
        print(f"DEBUG: open_column_history_dialog real_col={real_col}")
        # --- Исправление: если не нашли real_col, пробуем по переводу ---
        if not real_col and hasattr(self, 'param_translations'):
            for orig, tr in self.param_translations.items():
                if tr == col or tr.lower() == col.lower() or norm(tr) == norm(col):
                    if orig in df.columns:
                        real_col = orig
                        break
        if not real_col or real_col not in self.column_history:
            self.info_label.config(text=f'Нет истории для столбца {col}')
            return
        history = self.column_history[real_col]
        active_flags = self.column_history_active.get(real_col, [True]*len(history))
        dialog = Toplevel(self.root)
        dialog.title(f"История изменений: {real_col}")
        dialog.geometry("800x500")
        # --- Синхронизируем выпадающий список "Столбец:" ---
        if hasattr(self, 'replace_col_var'):
            columns_list = list(self.replace_col_menu['values']) if hasattr(self, 'replace_col_menu') else []
            # Если текущее значение есть в списке, выставляем его
            if current_display_col and current_display_col in columns_list:
                self.replace_col_var.set(current_display_col)
            else:
                # Если есть перевод, показываем его, иначе оригинал
                display_col = real_col
                if hasattr(self, 'param_translations'):
                    for orig, tr in self.param_translations.items():
                        if orig == real_col:
                            display_col = tr
                            break
                self.replace_col_var.set(display_col)
        Label(dialog, text=f"История изменений столбца: {real_col}").pack(pady=5)
        frame = Frame(dialog)
        frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
        # --- Список операций с чекбоксом и крестиком на одной строке ---
        op_frame = Frame(frame)
        op_frame.pack(fill=BOTH, expand=True)
        for idx, op in enumerate(self.column_op_history.get(real_col, [])):
            row = Frame(op_frame)
            row.pack(fill=X, pady=2)
            is_active = self.column_history_active[real_col][idx] if idx < len(self.column_history_active[real_col]) else True
            var = tk.BooleanVar(value=is_active)
            def make_toggle(idx_toggle, var=var):
                return lambda *_: self.toggle_column_history_op(real_col, idx_toggle, var.get(), dialog, real_col)
            chk = Checkbutton(row, variable=var, command=make_toggle(idx, var))
            chk.pack(side=LEFT)
            Label(row, text=f"{idx+1}. {op}", anchor='w').pack(side=LEFT, fill=X, expand=True)
            # --- Кнопка редактирования ---
            def make_edit(idx_edit):
                return lambda: edit_rule(real_col, idx_edit, dialog)
            btn_edit = Button(row, text="✎", command=make_edit(idx), width=2)
            btn_edit.pack(side=RIGHT)
            # --- Кнопки ▲▼ для изменения порядка ---
            def make_move_up(idx_move):
                return lambda: move_rule(real_col, idx_move, -1, dialog)
            def make_move_down(idx_move):
                return lambda: move_rule(real_col, idx_move, 1, dialog)
            btn_up = Button(row, text="▲", command=make_move_up(idx), width=2)
            btn_up.pack(side=RIGHT)
            btn_down = Button(row, text="▼", command=make_move_down(idx), width=2)
            btn_down.pack(side=RIGHT)
            def make_delete(idx_del):
                return lambda: delete_op(idx_del)
            btn = Button(row, text="✖", command=make_delete(idx), width=2, fg='red')
            btn.pack(side=RIGHT)
        # --- Функция для редактирования правила ---
        def edit_rule(col, idx, dialog):
            import re
            op = self.column_op_history[col][idx]
            m = re.search(r"Замена: '(.+)' → '(.*)' \(regex: (True|False)\)", op)
            if not m:
                from tkinter import messagebox
                messagebox.showerror('Ошибка', 'Можно редактировать только правила замены!')
                return
            find, replace, regex = m.group(1), m.group(2), m.group(3) == "True"
            edit_dialog = Toplevel(self.root)
            edit_dialog.title("Редактировать правило замены")
            Label(edit_dialog, text="Что заменить:").pack()
            entry_find = Entry(edit_dialog)
            entry_find.pack(fill='x', padx=12)
            entry_find.insert(0, find)
            Label(edit_dialog, text="На что заменить:").pack()
            entry_replace = Entry(edit_dialog)
            entry_replace.pack(fill='x', padx=12)
            entry_replace.insert(0, replace)
            var_regex = tk.BooleanVar(value=regex)
            chk_regex = Checkbutton(edit_dialog, text="Regex", variable=var_regex)
            chk_regex.pack()
            def save_edit():
                new_find = entry_find.get()
                new_replace = entry_replace.get()
                new_regex = var_regex.get()
                new_op = f"Замена: '{new_find}' → '{new_replace}' (regex: {'True' if new_regex else 'False'})"
                self.column_op_history[col][idx] = new_op
                self.save_column_history()
                self.recalc_column_from_history(col)
                edit_dialog.destroy()
                dialog.destroy()
                self.open_column_history_dialog(col)
            Button(edit_dialog, text="Сохранить", command=save_edit).pack(pady=5)
            Button(edit_dialog, text="Отмена", command=edit_dialog.destroy).pack()
        def move_rule(col, idx, direction, dialog):
            ops = self.column_op_history[col]
            acts = self.column_history_active[col]
            hist = self.column_history.get(col, [])
            new_idx = idx + direction
            if 0 <= new_idx < len(ops):
                # Меняем местами правило, активность и снапшот
                ops[idx], ops[new_idx] = ops[new_idx], ops[idx]
                acts[idx], acts[new_idx] = acts[new_idx], acts[idx]
                if len(hist) == len(ops):
                    hist[idx], hist[new_idx] = hist[new_idx], hist[idx]
                self.column_op_history[col] = ops
                self.column_history_active[col] = acts
                self.column_history[col] = hist
                self.save_column_history()
                self.recalc_column_from_history(col)
                dialog.destroy()
                self.open_column_history_dialog(col)
        def delete_op(idx_del):
            if real_col in self.column_op_history and idx_del < len(self.column_op_history[real_col]):
                del self.column_op_history[real_col][idx_del]
            if real_col in self.column_history and idx_del < len(self.column_history[real_col]):
                del self.column_history[real_col][idx_del]
            if real_col in self.column_history_active and idx_del < len(self.column_history_active[real_col]):
                del self.column_history_active[real_col][idx_del]
            self.save_column_history()
            # Пересчёт столбца из оригинала с применением только активных операций
            self.recalc_column_from_history(real_col)
            dialog.destroy()
            self.open_column_history_dialog(real_col)
        def clear_all():
            self.column_op_history[real_col] = []
            self.column_history[real_col] = []
            self.column_history_active[real_col] = []
            self.save_column_history()
            print(f"DEBUG: clear_all for {real_col}")
            if hasattr(self, '_pristine_table_df') and real_col in self._pristine_table_df.columns:
                self.current_df[real_col] = self._pristine_table_df[real_col]
            elif hasattr(self, '_pristine_df') and real_col in self._pristine_df.columns:
                self.current_df[real_col] = self._pristine_df[real_col]
            elif hasattr(self, 'original_df') and real_col in self.original_df.columns:
                self.current_df[real_col] = self.original_df[real_col]
            self.refresh_main_table_from_df()
            dialog.destroy()
        Button(dialog, text="Очистить все", command=clear_all, fg='red').pack(pady=10)

    # --- Реализация undo/redo и универсальных действий ---
    def undo_action(self, event=None):
        if not hasattr(self, 'undo_stack'):
            self.undo_stack = []
        if not hasattr(self, 'redo_stack'):
            self.redo_stack = []
        if self.undo_stack:
            last_state = self.undo_stack.pop()
            if hasattr(self, 'current_df'):
                self.redo_stack.append(self.current_df.copy())
                self.current_df = last_state
                self.refresh_main_table_from_df()
            # Для других вкладок
            if hasattr(self, 'active_tab') and self.active_tab == 'params_merge':
                if hasattr(self, 'params_merge_undo_stack') and self.params_merge_undo_stack:
                    state = self.params_merge_undo_stack.pop()
                    self.params_merge_redo_stack.append(self.param_merge_rules_manager.rules.copy())
                    self.param_merge_rules_manager.rules = state
                    self.setup_params_merge_tab()
            if hasattr(self, 'active_tab') and self.active_tab == 'values_translation':
                if hasattr(self, 'values_translation_undo_stack') and self.values_translation_undo_stack:
                    state = self.values_translation_undo_stack.pop()
                    self.values_translation_redo_stack.append(self.value_translation_rules.copy())
                    self.value_translation_rules = state
                    self.setup_values_translation_tab()

    def redo_action(self, event=None):
        if not hasattr(self, 'redo_stack'):
            self.redo_stack = []
        if self.redo_stack:
            state = self.redo_stack.pop()
            if hasattr(self, 'current_df'):
                self.save_undo_state()
                self.current_df = state
                self.refresh_main_table_from_df()
            # Для других вкладок
            if hasattr(self, 'active_tab') and self.active_tab == 'params_merge':
                if hasattr(self, 'params_merge_redo_stack') and self.params_merge_redo_stack:
                    state = self.params_merge_redo_stack.pop()
                    self.save_params_merge_undo_state()
                    self.param_merge_rules_manager.rules = state
                    self.setup_params_merge_tab()
            if hasattr(self, 'active_tab') and self.active_tab == 'values_translation':
                if hasattr(self, 'values_translation_redo_stack') and self.values_translation_redo_stack:
                    state = self.values_translation_redo_stack.pop()
                    self.save_values_translation_undo_state()
                    self.value_translation_rules = state
                    self.setup_values_translation_tab()

    def save_undo_state(self):
        if not hasattr(self, 'undo_stack'):
            self.undo_stack = []
        if not hasattr(self, 'redo_stack'):
            self.redo_stack = []
        import copy
        self.undo_stack.append(copy.deepcopy(self.current_df))
        self.redo_stack.clear()

    # Для других вкладок:
    def save_params_merge_undo_state(self):
        if not hasattr(self, 'params_merge_undo_stack'):
            self.params_merge_undo_stack = []
        if not hasattr(self, 'params_merge_redo_stack'):
            self.params_merge_redo_stack = []
        import copy
        self.params_merge_undo_stack.append(copy.deepcopy(self.param_merge_rules_manager.rules))
        self.params_merge_redo_stack.clear()

    def save_values_translation_undo_state(self):
        if not hasattr(self, 'values_translation_undo_stack'):
            self.values_translation_undo_stack = []
        if not hasattr(self, 'values_translation_redo_stack'):
            self.values_translation_redo_stack = []
        import copy
        self.values_translation_undo_stack.append(copy.deepcopy(self.value_translation_rules))
        self.values_translation_redo_stack.clear()

    def copy_selected_cell_event(self, event=None):
        # Копирование выделенных ячеек/строк в Excel-совместимом формате (TSV)
        import pyperclip
        selected = self.tree.selection()
        if not selected:
            return
        columns = self.tree['columns']
        data = []
        for item in selected:
            row = self.tree.item(item, 'values')
            data.append('\t'.join(str(row[i]) for i in range(len(columns))))
        pyperclip.copy('\n'.join(data))

    def paste_to_selected_cell(self, event=None):
        import pyperclip
        value = pyperclip.paste()
        rows = value.split('\n')
        selected = self.tree.selection()
        if not selected:
            return
        columns = self.tree['columns']
        for idx, item in enumerate(selected):
            if idx < len(rows):
                row_values = list(self.tree.item(item, 'values'))
                pasted = rows[idx].split('\t')
                for col_idx in range(min(len(pasted), len(row_values))):
                    # --- Сохраняем историю операций ---
                    col = columns[col_idx]
                    if col not in self.column_op_history:
                        self.column_op_history[col] = []
                    old_val = row_values[col_idx]
                    new_val = pasted[col_idx]
                    op_descr = f"Вставка из буфера: строка {idx}, '{old_val}' → '{new_val}'"
                    self.column_op_history[col].append(op_descr)
                    if col not in self.column_history:
                        self.column_history[col] = []
                    self.column_history[col].append(list(self.current_df[col]))
                    self.save_undo_state()
                    row_values[col_idx] = pasted[col_idx]
                self.tree.item(item, values=row_values)

    def select_all_cells(self, event=None):
        for item in self.tree.get_children():
            self.tree.selection_add(item)

    def focus_search_entry(self, event=None):
        # Фокус на поле поиска артикула
        if hasattr(self, 'search_var'):
            for widget in self.main_tab.winfo_children():
                if isinstance(widget, Frame):
                    for child in widget.winfo_children():
                        if isinstance(child, Entry):
                            child.focus_set()
                            return

    def refresh_main_table_from_df(self):
        print("DEBUG: refresh_main_table_from_df called")
        if not hasattr(self, 'current_df') or self.current_df is None:
            print("DEBUG: self.current_df is None")
            return
        df = self.current_df
        columns = list(df.columns)
        # Проверка на дубли и обрезанные имена
        if len(columns) != len(set(columns)):
            print(f"ERROR: Duplicate columns detected: {columns}")
        try:
            self.tree['columns'] = columns
        except Exception as e:
            print(f"ERROR: Exception when setting tree columns: {e}")
            print(f"DEBUG: columns attempted: {columns}")
            print(f"DEBUG: DataFrame columns: {list(df.columns)}")
            return
        self.tree.delete(*self.tree.get_children())
        for i, row in df.iterrows():
            values = [row.get(col, "") for col in columns]
            self.tree.insert('', 'end', values=values)
        # --- Синхронизация видимых столбцов ---
        if hasattr(self, 'visible_columns') and self.visible_columns:
            self.visible_columns = [col for col in self.visible_columns if col in columns]
        else:
            self.visible_columns = list(columns)
        if not self.visible_columns:
            self.visible_columns = list(columns)
        # --- Дополнительная защита: displaycolumns только существующие ---
        self.tree['displaycolumns'] = [col for col in self.visible_columns if col in columns]
        print(f"DEBUG: Table filled with {len(df)} rows and columns: {columns}")

    def on_main_tree_double_click(self, event):
        region = self.tree.identify('region', event.x, event.y)
        if region != 'cell':
            return
        rowid = self.tree.identify_row(event.y)
        col = self.tree.identify_column(event.x)
        if not rowid or not col:
            return
        col_idx = int(col.replace('#', '')) - 1
        columns = self.tree['columns']
        values = list(self.tree.item(rowid, 'values'))
        old_value = values[col_idx]
        entry = Entry(self.tree)
        entry.insert(0, old_value)
        entry.select_range(0, END)
        entry.focus_set()
        x, y, width, height = self.tree.bbox(rowid, col)
        entry.place(x=x, y=y, width=width, height=height)
        def save_and_destroy(event=None):
            new_value = entry.get().strip()
            if new_value != old_value:
                values[col_idx] = new_value
                self.tree.item(rowid, values=values)
                # Сохраняем в DataFrame
                idx = self.tree.index(rowid)
                col_name = columns[col_idx]
                self.current_df.at[self.current_df.index[idx], col_name] = new_value
                # --- Сохраняем историю операций ---
                if col_name not in self.column_op_history:
                    self.column_op_history[col_name] = []
                op_descr = f"Ручное редактирование: строка {idx}, '{old_value}' → '{new_value}'"
                self.column_op_history[col_name].append(op_descr)
                if col_name not in self.column_history:
                    self.column_history[col_name] = []
                self.column_history[col_name].append(list(self.current_df[col_name]))
            entry.destroy()
        entry.bind('<Return>', save_and_destroy)
        entry.bind('<FocusOut>', save_and_destroy)

    def update_extra_cols_label(self):
        # Обновляет отображение выбранных доп. столбцов с номерами
        extra_cols = self.settings.get('extra_cols', [])
        if hasattr(self, 'df_columns') and self.df_columns:
            display_text = ', '.join([
                f"{self.df_columns.index(col)+1}. {col}" if col in self.df_columns else col
                for col in extra_cols
            ]) if extra_cols else ''
        else:
            display_text = ', '.join(extra_cols) if extra_cols else ''
        self.extra_cols_label.config(text=display_text)

    def add_value_translation_rule(self):
        from dialogs import ValueTranslationRuleDialog
        dialog = ValueTranslationRuleDialog(self.root)
        if dialog.result:
            with open('value_translation_rules.json', encoding='utf-8') as f:
                rules = json.load(f)
            rules.append({"active": True, **dialog.result})
            with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
                json.dump(rules, f, ensure_ascii=False, indent=2)
            self.setup_values_translation_tab()
            self.fill_main_table_from_characteristics()

    def edit_value_translation_rule(self):
        from dialogs import ValueTranslationRuleDialog
        # Определяем выбранный индекс (по первому отмеченному чекбоксу)
        idx = None
        for i, var in enumerate(self.value_translation_vars):
            if var.get():
                idx = i
                break
        if idx is not None:
            with open('value_translation_rules.json', encoding='utf-8') as f:
                rules = json.load(f)
            dialog = ValueTranslationRuleDialog(self.root, rules[idx])
            if dialog.result:
                rules[idx].update(dialog.result)
                with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
                    json.dump(rules, f, ensure_ascii=False, indent=2)
                self.setup_values_translation_tab()
                self.fill_main_table_from_characteristics()

    def delete_value_translation_rule(self):
        # Удаляет первое отмеченное правило
        idx = None
        for i, var in enumerate(self.value_translation_vars):
            if var.get():
                idx = i
                break
        if idx is not None:
            with open('value_translation_rules.json', encoding='utf-8') as f:
                rules = json.load(f)
            del rules[idx]
            with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
                json.dump(rules, f, ensure_ascii=False, indent=2)
            self.setup_values_translation_tab()
            self.fill_main_table_from_characteristics()

    def setup_parameters_tab(self):
        for widget in self.parameters_tab.winfo_children():
            widget.destroy()
        columns = list(self.current_df.columns) if hasattr(self, 'current_df') and self.current_df is not None else []
        columns_sorted = sorted(columns, key=lambda x: x.lower())
        old_translations = getattr(self, 'param_translations', {})
        btns_frame = Frame(self.parameters_tab)
        btns_frame.pack(fill=X, padx=10, pady=5)
        Button(btns_frame, text="Экспорт в JSON", command=self.export_params_json).pack(side=LEFT, padx=2)
        Button(btns_frame, text="Импорт из JSON", command=self.import_params_json).pack(side=LEFT, padx=2)
        Button(btns_frame, text="Экспорт в XLSX", command=self.export_params_xlsx).pack(side=LEFT, padx=2)
        Button(btns_frame, text="Импорт из XLSX", command=self.import_params_xlsx).pack(side=LEFT, padx=2)
        Label(self.parameters_tab, text="Перевод в столбце 'Перевод параметра' формируется автоматически через BuiltinTranslationSystem (title case + словарь переводов), но вы можете вручную отредактировать перевод двойным кликом.", fg="blue").pack(fill=X, padx=10, pady=(0,5))
        tree_frame = Frame(self.parameters_tab)
        tree_frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
        self.param_tree = ttk.Treeview(tree_frame, columns=("num", "param", "translation", "orig"), show="headings", height=20, selectmode="extended")
        self.param_tree.heading("num", text="№")
        self.param_tree.heading("param", text="Исходные параметры")
        self.param_tree.heading("translation", text="Перевод параметра")
        self.param_tree.column("num", width=40, anchor=CENTER)
        self.param_tree.column("param", width=250, anchor=W)
        self.param_tree.column("translation", width=250, anchor=W)
        self.param_tree.column("orig", width=0, stretch=False, anchor=W, minwidth=0)
        self.param_tree.pack(side=LEFT, fill=BOTH, expand=True)
        vsb = Scrollbar(tree_frame, orient="vertical", command=self.param_tree.yview)
        self.param_tree.configure(yscrollcommand=vsb.set)
        vsb.pack(side=RIGHT, fill=Y)
        self.param_translation_vars = {}
        for idx, orig in enumerate(columns_sorted, 1):
            norm_orig = normalize_param_name(orig)
            translation = old_translations.get(norm_orig)
            if translation is None:
                translation = self.translation_system.translate_text(orig, is_parameter=True) if self.translation_system else orig
            self.param_tree.insert("", "end", values=(idx, orig, translation, orig))
            self.param_translation_vars[orig] = translation
        self.param_tree.bind('<Double-1>', self.on_param_tree_double_click)
        self.param_menu = tk.Menu(self.param_tree, tearoff=0)
        self.param_menu.add_command(label="Объединить параметры и их значения", command=self.merge_selected_params)
        self.param_tree.bind('<Button-3>', self.show_param_context_menu)
        self.load_param_translations_from_file()
        self.init_param_translations_from_tree()
        self.refresh_main_table_headers_with_translations()

    def load_param_translations_from_file(self):
        import os, json
        file = 'param_translations.json'
        if os.path.exists(file):
            with open(file, 'r', encoding='utf-8') as f:
                translations = json.load(f)
            # Обновляем дерево параметров
            for item in self.param_tree.get_children():
                param = self.param_tree.set(item, "param")
                norm_param = normalize_param_name(param)
                if norm_param in translations:
                    self.param_tree.set(item, "translation", translations[norm_param])
            self.param_translations = translations
        else:
            self.param_translations = {normalize_param_name(self.param_tree.set(item, "param")): self.param_tree.set(item, "translation") for item in self.param_tree.get_children()}

    def init_param_translations_from_tree(self):
        # Инициализирует self.param_translations из текущего дерева параметров
        if hasattr(self, 'param_tree'):
            columns = list(self.current_df.columns) if hasattr(self, 'current_df') and self.current_df is not None else []
            self.param_translations = {self.param_tree.set(item, "param"): self.param_tree.set(item, "translation") for item in self.param_tree.get_children() if self.param_tree.set(item, "param") in columns}

    def on_param_tree_double_click(self, event):
        item = self.param_tree.identify_row(event.y)
        column = self.param_tree.identify_column(event.x)
        if not item or column != '#3':  # '#3' — это столбец 'Перевод параметра'
            return
        x, y, width, height = self.param_tree.bbox(item, column)
        value = self.param_tree.set(item, "translation")
        entry = Entry(self.param_tree)
        entry.insert(0, value)
        entry.select_range(0, END)
        entry.focus_set()
        entry.place(x=x, y=y, width=width, height=height)
        def save_edit(event=None):
            new_value = entry.get().strip()
            self.param_tree.set(item, "translation", new_value)
            orig = self.param_tree.set(item, "orig")
            self.param_translation_vars[orig] = new_value
            entry.destroy()
            self.save_param_translations()  # автосохранение
            self.refresh_main_table_headers_with_translations()  # немедленно обновить заголовки
        entry.bind('<Return>', save_edit)
        entry.bind('<FocusOut>', save_edit)

    def save_param_translations(self):
        import json
        # Сохраняем только переводы для реально существующих столбцов
        columns = list(self.current_df.columns) if hasattr(self, 'current_df') and self.current_df is not None else []
        param_translations = {}
        for item in self.param_tree.get_children():
            orig = self.param_tree.set(item, "orig")
            translation = self.param_tree.set(item, "translation")
            norm_orig = normalize_param_name(orig)
            if norm_orig in [normalize_param_name(col) for col in columns]:
                param_translations[norm_orig] = translation
        self.param_translations = param_translations
        with open('param_translations.json', 'w', encoding='utf-8') as f:
            json.dump(self.param_translations, f, ensure_ascii=False, indent=2)
        if hasattr(self, 'info_label') and self.info_label:
            self.info_label.config(text="Переводы параметров сохранены")
        self.refresh_main_table_headers_with_translations()
        self.update_replace_col_menu()

    def refresh_main_table_headers_with_translations(self):
        # Обновляет заголовки столбцов основной таблицы на переводы
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        df = self.current_df
        columns = list(df.columns)
        translations = getattr(self, 'param_translations', None)
        print("DEBUG: DataFrame columns:")
        for col in columns:
            print(f"  {repr(col)} (norm: {normalize_param_name(col)})")
        print("DEBUG: param_translations keys:")
        for k in (translations or {}):
            print(f"  {repr(k)} (norm: {normalize_param_name(k)}) -> {translations[k]}")
        for col in columns:
            display = get_param_translation(col, translations)
            norm_col = normalize_param_name(col)
            debug_msg = f"col: {repr(col)} | norm: {norm_col} | "
            if display:
                debug_msg += f"TRANSLATION FOUND: {display}"
            else:
                if hasattr(self, 'translation_system') and self.translation_system:
                    display = self.translation_system.translate_text(col, is_parameter=True)
                    debug_msg += f"AUTO-TRANSLATED: {display}"
                if not display:
                    display = col
                    debug_msg += f"ORIGINAL USED: {display}"
            print(debug_msg)
            self.tree.heading(col, text=display)
            max_len = max([len(str(x)) for x in [display] + list(df[col])] + [10])
            width = min(max_len * 10, 800)
            self.tree.column(col, width=width, minwidth=80, stretch=False)
        self.update_replace_col_menu()

    def edit_value_translation_rule_by_index(self, idx):
        import json
        from tkinter import messagebox
        with open('value_translation_rules.json', encoding='utf-8') as f:
            rules = json.load(f)
        if idx < 0 or idx >= len(rules):
            messagebox.showinfo("Редактировать правило", "Некорректный индекс правила.")
            return
        rule = rules[idx]
        # --- Новый диалог ---
        dialog = Toplevel(self.root)
        dialog.title("Редактировать правило")
        dialog.geometry("350x180")
        dialog.grab_set()
        Label(dialog, text="Исходное значение (единица):").pack(pady=(12,2), anchor='w', padx=12)
        entry_orig = Entry(dialog)
        entry_orig.pack(fill='x', padx=12)
        entry_orig.insert(0, rule['original'])
        Label(dialog, text="Перевод:").pack(pady=(10,2), anchor='w', padx=12)
        entry_trans = Entry(dialog)
        entry_trans.pack(fill='x', padx=12)
        entry_trans.insert(0, rule['translation'])
        Label(dialog, text="Описание (необязательно):").pack(pady=(10,2), anchor='w', padx=12)
        entry_desc = Entry(dialog)
        entry_desc.pack(fill='x', padx=12)
        entry_desc.insert(0, rule.get('description',''))
        def on_ok():
            orig = entry_orig.get()
            trans = entry_trans.get()
            desc = entry_desc.get()
            if not orig or not trans:
                messagebox.showwarning("Ошибка", "Заполните оба поля: исходное значение и перевод.")
                return
            rules[idx] = {'original': orig, 'translation': trans, 'description': desc or '', 'active': rule.get('active', True)}
            with open('value_translation_rules.json', 'w', encoding='utf-8') as f:
                json.dump(rules, f, ensure_ascii=False, indent=2)
            dialog.destroy()
            self.setup_values_translation_tab()
            self.fill_main_table_from_characteristics()
        def on_cancel():
            dialog.destroy()
        btns = Frame(dialog)
        btns.pack(pady=12)
        Button(btns, text="OK", width=10, command=on_ok).pack(side=LEFT, padx=8)
        Button(btns, text="Cancel", width=10, command=on_cancel).pack(side=LEFT, padx=8)
        entry_orig.focus_set()

    def show_param_context_menu(self, event):
        selection = self.param_tree.selection()
        if len(selection) >= 2:
            self.param_menu.tk_popup(event.x_root, event.y_root)

    def merge_selected_params(self):
        selection = self.param_tree.selection()
        if len(selection) < 2:
            return
        # Получаем пары (оригинал, перевод) для выбранных параметров
        param_pairs = [(self.param_tree.set(item, "param"), self.param_tree.set(item, "translation")) for item in selection]
        # Для радиокнопок: показываем перевод + оригинал, значение — оригинал
        radio_options = [(f"{trans} ({orig})", orig) for orig, trans in param_pairs]
        # Диалог выбора итогового названия
        top = Toplevel(self.root)
        top.title("Выберите итоговое название параметра")
        Label(top, text="Выберите перевод, который останется после объединения:").pack(padx=10, pady=10)
        var = tk.StringVar(value=radio_options[0][1])
        for label, value in radio_options:
            Radiobutton(top, text=label, variable=var, value=value).pack(anchor=W, padx=10)
        def do_merge():
            target_orig = var.get()
            try:
                self.perform_param_merge([orig for orig, _ in param_pairs], target_orig)
            finally:
                top.destroy()
                self.save_param_translations()  # автосохранение после объединения
        Button(top, text="Объединить", command=do_merge).pack(pady=10)

    def perform_param_merge(self, params, target):
        print(f"DEBUG: perform_param_merge called with params={params}, target={target}")
        if not hasattr(self, 'current_df') or self.current_df is None:
            print("DEBUG: self.current_df is None")
            return
        df = self.current_df.copy()
        print(f"DEBUG: df.shape before merge: {df.shape}")
        merged_values = []
        for p in params:
            if p in df.columns:
                print(f"DEBUG: column '{p}' found in df.columns")
                merged_values.append(df[p].astype(str))
            else:
                print(f"DEBUG: column '{p}' NOT found in df.columns")
        if merged_values:
            import pandas as pd
            merged = merged_values[0].copy()
            for mv in merged_values[1:]:
                merged = merged + '; ' + mv
            df[target] = merged
        # Удаляем все объединённые столбцы, кроме target
        for p in params:
            if p != target and p in df.columns:
                df.drop(columns=[p], inplace=True)
        print(f"DEBUG: df.shape after merge: {df.shape}")
        print(f"DEBUG: df.columns after merge: {list(df.columns)}")
        print(f"DEBUG: df.head after merge:\n{df.head()}\n")
        self.current_df = df
        # --- Сброс видимых столбцов только по новым ---
        self.visible_columns = list(df.columns)
        self.setup_parameters_tab()
        # --- Дополнительная защита: сбросить displaycolumns ---
        self.tree['displaycolumns'] = self.visible_columns
        self.refresh_main_table_from_df()
        # --- Сохраняем правило объединения ---
        import os, json
        merge_rule = {'params': params, 'target': target}
        rules_file = 'param_merge_rules.json'
        rules = []
        if os.path.exists(rules_file):
            try:
                with open(rules_file, 'r', encoding='utf-8') as f:
                    rules = json.load(f)
            except Exception:
                rules = []
        # Не добавлять дубликаты
        if not any(set(r.get('params', [])) == set(params) and r.get('target') == target for r in rules):
            rules.append(merge_rule)
            with open(rules_file, 'w', encoding='utf-8') as f:
                json.dump(rules, f, ensure_ascii=False, indent=2)

    def export_params_json(self):
        import json
        params = [{
            'param': self.param_tree.set(item, 'param'),
            'translation': self.param_tree.set(item, 'translation')
        } for item in self.param_tree.get_children()]
        file = filedialog.asksaveasfilename(defaultextension='.json', filetypes=[('JSON files', '*.json')])
        if file:
            with open(file, 'w', encoding='utf-8') as f:
                json.dump(params, f, ensure_ascii=False, indent=2)
            self.info_label.config(text=f'Экспортировано в {file}')
            self.save_param_translations()  # автосохранение после экспорта

    def import_params_json(self):
        import json
        file = filedialog.askopenfilename(filetypes=[('JSON files', '*.json')])
        if file:
            with open(file, 'r', encoding='utf-8') as f:
                params = json.load(f)
            self.param_tree.delete(*self.param_tree.get_children())
            df_columns = list(self.current_df.columns) if hasattr(self, 'current_df') and self.current_df is not None else []
            norm_df_columns = {normalize_param_name(col): col for col in df_columns}
            param_translations = {}
            for idx, row in enumerate(params, 1):
                orig = row['param']
                translation = row['translation']
                self.param_tree.insert('', 'end', values=(idx, orig, translation, orig))
                norm_orig = normalize_param_name(orig)
                if norm_orig in norm_df_columns:
                    df_col = norm_df_columns[norm_orig]
                    param_translations[df_col] = translation
            self.info_label.config(text=f'Импортировано из {file}')
            self.param_translations = param_translations
            self.refresh_main_table_headers_with_translations()
            self.save_param_translations()

    def export_params_xlsx(self):
        import openpyxl
        from openpyxl import Workbook
        params = [{
            'param': self.param_tree.set(item, 'param'),
            'translation': self.param_tree.set(item, 'translation')
        } for item in self.param_tree.get_children()]
        file = filedialog.asksaveasfilename(defaultextension='.xlsx', filetypes=[('Excel files', '*.xlsx')])
        if file:
            wb = Workbook()
            ws = wb.active
            ws.append(['Исходные параметры', 'Перевод параметра'])
            for row in params:
                ws.append([row['param'], row['translation']])
            wb.save(file)
            self.info_label.config(text=f'Экспортировано в {file}')
            self.save_param_translations()  # автосохранение после экспорта

    def import_params_xlsx(self):
        import openpyxl
        file = filedialog.askopenfilename(filetypes=[('Excel files', '*.xlsx')])
        if file:
            wb = openpyxl.load_workbook(file)
            ws = wb.active
            rows = list(ws.iter_rows(values_only=True))
            self.param_tree.delete(*self.param_tree.get_children())
            df_columns = list(self.current_df.columns) if hasattr(self, 'current_df') and self.current_df is not None else []
            norm_df_columns = {normalize_param_name(col): col for col in df_columns}
            param_translations = {}
            for idx, row in enumerate(rows[1:], 1):
                orig = row[0]
                translation = row[1]
                self.param_tree.insert('', 'end', values=(idx, orig, translation, orig))
                norm_orig = normalize_param_name(orig)
                if norm_orig in norm_df_columns:
                    df_col = norm_df_columns[norm_orig]
                    param_translations[df_col] = translation
            self.info_label.config(text=f'Импортировано из {file}')
            self.param_translations = param_translations
            self.refresh_main_table_headers_with_translations()
            self.save_param_translations()

    def setup_typos_tab(self):
        import json, os
        for widget in self.typos_tab.winfo_children():
            widget.destroy()
        Label(self.typos_tab, text="Пользовательские правила исправления опечаток", font=("Arial", 12, "bold")).pack(pady=10)
        # --- Загрузка правил ---
        typo_rules_file = 'typo_rules.json'
        if os.path.exists(typo_rules_file):
            with open(typo_rules_file, encoding='utf-8') as f:
                typo_rules = json.load(f)
        else:
            typo_rules = []
        # --- UI: таблица правил ---
        from tkinter import ttk, simpledialog, messagebox
        frame = Frame(self.typos_tab)
        frame.pack(fill="both", expand=True, padx=10, pady=5)
        columns = ("active", "orig", "suggest", "edit", "delete")
        tree = ttk.Treeview(frame, columns=columns, show="headings", height=15)
        tree.heading("active", text="Активно")
        tree.heading("orig", text="Исходное значение")
        tree.heading("suggest", text="Вариант замены")
        tree.heading("edit", text="Редактировать")
        tree.heading("delete", text="Удалить")
        tree.column("active", width=70, anchor='center')
        tree.column("orig", width=220)
        tree.column("suggest", width=220)
        tree.column("edit", width=100, anchor='center')
        tree.column("delete", width=100, anchor='center')
        tree.pack(fill="both", expand=True, side=LEFT)
        # --- Список переменных активности ---
        self.typo_rule_vars = []
        for idx, rule in enumerate(typo_rules):
            active = rule.get('active', True)
            tree.insert("", "end", iid=str(idx), values=("✔" if active else "", rule['orig'], rule['suggest'], "Редактировать", "Удалить"))
            self.typo_rule_vars.append(active)
        # --- Обработчики кликов по кнопкам ---
        def on_tree_click(event):
            item = tree.identify_row(event.y)
            col = tree.identify_column(event.x)
            if not item:
                return
            idx = int(item)
            if col == '#4':  # Редактировать
                rule = typo_rules[idx]
                new_orig = simpledialog.askstring("Редактировать исходное значение", "Новое исходное значение:", initialvalue=rule['orig'])
                if new_orig is None:
                    return
                new_suggest = simpledialog.askstring("Редактировать вариант замены", "Новый вариант замены:", initialvalue=rule['suggest'])
                if new_suggest is None:
                    return
                typo_rules[idx]['orig'] = new_orig
                typo_rules[idx]['suggest'] = new_suggest
                save_typos()
                self.setup_typos_tab()
            elif col == '#5':  # Удалить
                if messagebox.askyesno("Удалить правило", "Удалить это правило?"):
                    typo_rules.pop(idx)
                    save_typos()
                    self.setup_typos_tab()
            elif col == '#1':  # Активно (чекбокс)
                typo_rules[idx]['active'] = not typo_rules[idx].get('active', True)
                save_typos()
                self.setup_typos_tab()
        tree.bind('<Button-1>', on_tree_click)
        # --- Кнопка добавления ---
        def add_rule():
            orig = simpledialog.askstring("Новое правило", "Исходное значение:")
            if not orig:
                return
            suggest = simpledialog.askstring("Новое правило", "Вариант замены:")
            if suggest is None:
                return
            typo_rules.append({'orig': orig, 'suggest': suggest, 'active': True})
            save_typos()
            self.setup_typos_tab()
        Button(self.typos_tab, text="Добавить правило", command=add_rule).pack(pady=10)
        # --- Кнопка применения ---
        def apply_typos():
            if not hasattr(self, 'current_df') or self.current_df is None:
                return
            df = self.current_df.copy()
            for rule in typo_rules:
                if rule.get('active', True):
                    orig = rule['orig']
                    suggest = rule['suggest']
                    if orig:
                        for col in df.columns:
                            if col == 'Артикул':
                                continue
                            df[col] = df[col].astype(str).apply(lambda x: x.replace(orig, suggest))
            self.current_df = df
            self.refresh_main_table_from_df()
        Button(self.typos_tab, text="Применить выбранные", command=apply_typos).pack(pady=10)
        # --- Сохранение ---
        def save_typos():
            with open(typo_rules_file, 'w', encoding='utf-8') as f:
                json.dump(typo_rules, f, ensure_ascii=False, indent=2)

    def update_replace_col_menu(self):
        # Обновляет выпадающий список столбцов с учетом переводов параметров
        if not hasattr(self, 'current_df') or self.current_df is None:
            return
        if hasattr(self, 'replace_col_menu'):
            # Получаем переводы из param_tree (столбец 'translation')
            translations = []
            for item in self.param_tree.get_children():
                translation = self.param_tree.set(item, 'translation')
                if translation and translation != 'Артикул':
                    translations.append(translation)
            self.replace_col_menu['values'] = translations
            if translations:
                self.replace_col_var.set(translations[0])
            self.replace_col_menu.update_idletasks()

    def export_to_csv(self):
        import pandas as pd
        from tkinter import filedialog
        if not hasattr(self, 'current_df') or self.current_df is None:
            self.info_label.config(text='Нет данных для экспорта')
            return
        file = filedialog.asksaveasfilename(defaultextension='.csv', filetypes=[('CSV files', '*.csv')])
        if file:
            self.current_df.to_csv(file, index=False, encoding='utf-8-sig')
            self.info_label.config(text=f'Экспортировано в {file}')

    def save_column_history(self):
        # Сохраняет историю изменений столбцов
        data = {
            'column_history': self.column_history,
            'column_op_history': self.column_op_history,
            'column_history_active': getattr(self, 'column_history_active', {})
        }
        with open('column_history.json', 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)

    def load_column_history(self):
        # Загружает историю изменений столбцов
        import os
        if os.path.exists('column_history.json'):
            with open('column_history.json', 'r', encoding='utf-8') as f:
                data = json.load(f)
            self.column_history = data.get('column_history', {})
            self.column_op_history = data.get('column_op_history', {})
            self.column_history_active = data.get('column_history_active', {})
        else:
            self.column_history = {}
            self.column_op_history = {}
            self.column_history_active = {}
        # --- Делаем все правила активными ---
        for col, ops in self.column_op_history.items():
            self.column_history_active[col] = [True] * len(ops)
        # --- Не вызываем пересчёт здесь, чтобы избежать ошибки при отсутствии данных ---

    def parse_td_lines(self, td):
        lines = []
        for content in td.contents:
            tag_name = getattr(content, 'name', None)
            # Разделитель для <br>, <p>, <div>, <li>
            if tag_name in ('br', 'p', 'div', 'li'):
                lines.append('\n')
                if tag_name != 'br':
                    # Для <p>, <div>, <li> — добавить их текст
                    lines.append(content.get_text(separator=' ', strip=True))
            else:
                if isinstance(content, str):
                    lines.append(content)
                else:
                    lines.append(content.get_text(separator=' ', strip=True))
        full_text = ''.join(lines)
        lines_cleaned = [line.strip() for line in full_text.split('\n') if line.strip()]
        return lines_cleaned

    def recalc_column_from_history(self, real_col):
        import re
        print(f"DEBUG: recalc_column_from_history for {real_col}")
        # 1. Взять оригинальные значения
        if hasattr(self, '_pristine_table_df') and real_col in self._pristine_table_df.columns:
            print('PRISTINE_TABLE:', list(self._pristine_table_df[real_col])[:5])
            col_data = list(self._pristine_table_df[real_col])
        elif hasattr(self, '_pristine_df') and real_col in self._pristine_df.columns:
            print('PRISTINE:', list(self._pristine_df[real_col])[:5])
            col_data = list(self._pristine_df[real_col])
        elif hasattr(self, 'original_df') and real_col in self.original_df.columns:
            col_data = list(self.original_df[real_col])
        else:
            print(f"ERROR: {real_col} not in pristine_table_df.columns: {getattr(self, '_pristine_table_df', None) and list(self._pristine_table_df.columns)}")
            return
        print("Original values:", col_data[:5])
        # 2. Применить только активные операции (True = активно)
        ops = self.column_op_history.get(real_col, [])
        active_flags = self.column_history_active.get(real_col, [True]*len(ops))
        print('ops:', ops)
        print('active_flags:', active_flags)
        for idx, (op, active) in enumerate(zip(ops, active_flags)):
            if not active:
                continue  # Только если True — применяем правило
            m = re.search(r"Замена: '(.+)' → '(.*)' \(regex: (True|False)\)", op)
            if m:
                find, replace, regex = m.group(1), m.group(2), m.group(3) == "True"
                print(f'  Применяем правило {idx+1}: {find} -> {replace!r} (regex={regex})')
                print('  До:', col_data[:5])
                # Гарантируем, что замена выполняется даже если replace == ''
                if regex:
                    col_data = [re.sub(find, replace, str(x), flags=re.IGNORECASE) for x in col_data]
                else:
                    col_data = [str(x).replace(find, replace) for x in col_data]
                print('  После:', col_data[:5])
            else:
                print(f'  [!] Не распознано как правило замены: {op}')
        print("Current values after:", col_data[:5])
        # 3. Обновить столбец
        self.current_df[real_col] = col_data
        self.refresh_main_table_from_df()

    def toggle_column_history_op(self, col, idx, value, dialog=None, real_col=None):
        print(f"DEBUG: toggle_column_history_op called for col={col}, idx={idx}, value={value}")
        if col in self.column_history_active:
            self.column_history_active[col][idx] = value
            print(f"DEBUG: Active flags for {col}: {self.column_history_active[col]}")
        else:
            print(f"DEBUG: No active flags for {col}")
        self.recalc_column_from_history(col)
        self.save_column_history()
        if dialog is not None and real_col is not None:
            dialog.destroy()
            self.open_column_history_dialog(real_col)

    def on_value_translation_toggle(self, idx):
        # Этот метод должен вызываться при изменении активности правила перевода значений
        if idx < len(self.value_translation_rules):
            rule = self.value_translation_rules[idx]
            rule['active'] = not rule.get('active', True)
            self.save_value_translation_rules()
            self.apply_value_translation_rules()
            self.refresh_main_table_from_df()  # Обновляем таблицу после изменения

    def save_profile(self):
        import json
        from tkinter import simpledialog, filedialog
        profile_name = simpledialog.askstring("Сохранить профиль", "Введите имя профиля:")
        if not profile_name:
            return
        profile = {
            'param_translations': self.param_translations,
            'column_history': getattr(self, 'column_history', {}),
            'column_op_history': getattr(self, 'column_op_history', {}),
            'column_history_active': getattr(self, 'column_history_active', {}),
            'visible_columns': getattr(self, 'visible_columns', []),
            'settings': getattr(self, 'settings', {}),
            'last_file': getattr(self, 'last_file', ''),
        }
        file = filedialog.asksaveasfilename(defaultextension='.json', initialfile=f'{profile_name}.json', filetypes=[('JSON files', '*.json')])
        if file:
            with open(file, 'w', encoding='utf-8') as f:
                json.dump(profile, f, ensure_ascii=False, indent=2)
            with open('last_profile.txt', 'w', encoding='utf-8') as f:
                f.write(file)
            self.info_label.config(text=f'Профиль сохранён: {file}')

    def load_profile(self):
        import json
        from tkinter import filedialog
        file = filedialog.askopenfilename(filetypes=[('JSON files', '*.json')])
        if not file:
            return
        with open(file, 'r', encoding='utf-8') as f:
            profile = json.load(f)
        self.param_translations = profile.get('param_translations', {})
        self.column_history = profile.get('column_history', {})
        self.column_op_history = profile.get('column_op_history', {})
        self.column_history_active = profile.get('column_history_active', {})
        self.visible_columns = profile.get('visible_columns', [])
        self.settings = profile.get('settings', {})
        self.last_file = profile.get('last_file', '')
        self.save_param_translations()
        self.refresh_main_table_headers_with_translations()
        self.update_replace_col_menu()
        with open('last_profile.txt', 'w', encoding='utf-8') as f:
            f.write(file)
        self.info_label.config(text=f'Профиль загружен: {file}')

    def auto_load_last_profile(self):
        import os, json
        if os.path.exists('last_profile.txt'):
            with open('last_profile.txt', 'r', encoding='utf-8') as f:
                file = f.read().strip()
            if file and os.path.exists(file):
                with open(file, 'r', encoding='utf-8') as pf:
                    profile = json.load(pf)
                self.param_translations = profile.get('param_translations', {})
                self.column_history = profile.get('column_history', {})
                self.column_op_history = profile.get('column_op_history', {})
                self.column_history_active = profile.get('column_history_active', {})
                self.visible_columns = profile.get('visible_columns', [])
                self.settings = profile.get('settings', {})
                self.last_file = profile.get('last_file', '')
                self.save_param_translations()
                self.refresh_main_table_headers_with_translations()
                self.update_replace_col_menu()
                self.info_label.config(text=f'Профиль автоматически загружен: {file}')

    # --- Автосохранение переводов при изменении ---
    def on_param_translation_edit(self, event=None):
        # ... существующий код редактирования ...
        self.save_param_translations()

def normalize_param_name(name):
    import re
    name = re.sub(r'[-_]', ' ', name)
    name = re.sub(r'\s+', ' ', name)
    name = name.strip().lower()
    return name

def normalize_value(val):
    # Привести все кавычки к обычным двойным
    val = val.replace("'", '"').replace("'", '"').replace("''", '"').replace("`", '"').replace("ʼ", '"')
    val = val.replace(""", '"').replace(""", '"').replace("«", '"').replace("»", '"')
    val = val.replace("'", '"')
    # Удалить лишние пробелы вокруг знаков препинания и внутри строки
    import re
    val = re.sub(r'\s+', ' ', val)
    val = re.sub(r'\s*([,;/])\s*', r'\1 ', val)
    val = val.strip()
    return val

def remove_duplicates_normalized(values):
    seen = set()
    result = []
    for v in values:
        norm = normalize_value(v)
        if norm not in seen:
            seen.add(norm)
            result.append(norm)
    return result

def get_param_translation(col_name, param_translations):
    """
    Возвращает перевод параметра по нормализованному имени.
    Если не найдено — возвращает None.
    """
    norm_col = normalize_param_name(col_name)
    for key, value in (param_translations or {}).items():
        if normalize_param_name(key) == norm_col:
            return value
    return None

if __name__ == "__main__":
    root = tk.Tk()
    app = AttributeEditor(root)
    root.mainloop()

Проблема: не появляются заголовки в столбцах на вкладке Основная таблица при загрузке исходного файла эксель. Также не работает функционал замен в столбцах, хотя правила активны. Внеси необходимые исправления и вышли мне полный исправленный файл.